From c9ed832459ff7c14b4cb24cc7ac57d688e689418 Mon Sep 17 00:00:00 2001 From: Talon Date: Sat, 19 Sep 2026 22:40:48 +0200 Subject: [PATCH] Retire legacy sources and verify managed iOS deployment --- .github/workflows/dotnet.yml | 37 +- AGENTS.md | 141 +- CLAUDE.md | 211 +-- CMakeLists.txt | 9 +- Dockerfile | 2 +- PROGRESS.md | 1609 +---------------- README.md | 83 +- clients/apple/README.md | 2 +- clients/apple/dotnet/README.md | 9 +- .../dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj | 2 +- .../AppIcon.appiconset/AppIcon-1024.png | Bin .../AppIcon.appiconset/Contents.json | 0 .../Assets.xcassets/Contents.json | 0 .../dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj | 4 +- .../VoiceCat.iOS/build-broadcast-extension.sh | 14 +- clients/apple/scripts/build-xcframework.sh | 2 +- clients/windows/README.md | 2 +- core/CMakeLists.txt | 8 +- core/src/audio/apm_processor.cpp | 2 +- core/src/codec/opus_codec.h | 2 +- core/src/protocol/protocol.h | 2 +- docs/README.md | 93 +- docs/api-dotnet.md | 10 +- docs/architecture.md | 289 +-- docs/building.md | 567 +----- docs/ios-deploy.md | 95 + docs/porting-to-dotnet.md | 965 ---------- docs/protocol.md | 2 +- docs/roadmap.md | 212 +-- docs/security.md | 6 +- docs/tech-stack.md | 132 +- docs/voice.md | 8 +- dotnet-tools.json | 13 + dotnet/README.md | 212 +-- dotnet/build-native-ios.sh | 2 +- dotnet/build-native.ps1 | 2 +- dotnet/compare-dsp-fixtures.ps1 | 13 - dotnet/oracle/CMakeLists.txt | 29 - dotnet/oracle/database.cpp | 41 - dotnet/oracle/dsp.cpp | 30 - dotnet/oracle/main.cpp | 56 - dotnet/oracle/passwords.cpp | 29 - dotnet/oracle/tls.cpp | 76 - dotnet/oracle/voice.cpp | 127 -- dotnet/src/VoiceCat.Audio/packages.lock.json | 2 +- dotnet/src/VoiceCat.Codec/packages.lock.json | 2 +- dotnet/src/VoiceCat.Core/packages.lock.json | 2 +- dotnet/src/VoiceCat.Crypto/packages.lock.json | 2 +- dotnet/src/VoiceCat.Dsp/packages.lock.json | 2 +- .../VoiceCat.Protocol.csproj | 2 +- .../src/VoiceCat.Protocol/packages.lock.json | 2 +- .../tests/VoiceCat.Tests/AccountStoreTests.cs | 66 - .../VoiceCat.Tests/AdministrationTests.cs | 35 - dotnet/tests/VoiceCat.Tests/DspTests.cs | 2 +- .../{cpp-passwords.json => argon2id.json} | 0 .../Fixtures/{cpp-noise.json => rnnoise.json} | 0 .../Fixtures/{cpp-wire.json => wire.json} | 0 dotnet/tests/VoiceCat.Tests/GoldenTests.cs | 6 +- dotnet/tests/VoiceCat.Tests/IdentityTests.cs | 2 +- .../tests/VoiceCat.Tests/ManagedCliTests.cs | 24 - .../tests/VoiceCat.Tests/MediaRelayTests.cs | 84 - dotnet/tests/VoiceCat.Tests/PasswordTests.cs | 2 +- dotnet/tests/VoiceCat.Tests/ServerTests.cs | 37 - .../tests/VoiceCat.Tests/TlsInteropTests.cs | 97 - .../apple/broadcast}/BroadcastAudioRing.swift | 0 .../apple/broadcast}/Info.plist | 4 +- .../apple/broadcast}/SampleHandler.swift | 0 .../broadcast}/VoiceCatBroadcast.entitlements | 7 +- .../project.pbxproj | 285 +++ .../contents.xcworkspacedata | 7 + native/apple/broadcast/project.yml | 32 + .../native => native/media}/CMakeLists.txt | 2 +- {dotnet/native => native/media}/NOTICE.txt | 0 {dotnet/native => native/media}/media.c | 0 {third_party => native}/rnnoise/AUTHORS | 0 {third_party => native}/rnnoise/COPYING | 0 {third_party => native}/rnnoise/README | 0 .../rnnoise/include/rnnoise.h | 0 .../rnnoise/src/_kiss_fft_guts.h | 0 {third_party => native}/rnnoise/src/arch.h | 0 .../rnnoise/src/celt_lpc.c | 0 .../rnnoise/src/celt_lpc.h | 0 {third_party => native}/rnnoise/src/common.h | 0 .../rnnoise/src/cpu_support.h | 0 {third_party => native}/rnnoise/src/denoise.c | 0 {third_party => native}/rnnoise/src/denoise.h | 0 .../rnnoise/src/kiss_fft.c | 0 .../rnnoise/src/kiss_fft.h | 0 {third_party => native}/rnnoise/src/nnet.c | 0 {third_party => native}/rnnoise/src/nnet.h | 0 .../rnnoise/src/nnet_arch.h | 0 .../rnnoise/src/nnet_default.c | 0 .../rnnoise/src/opus_types.h | 0 .../rnnoise/src/parse_lpcnet_weights.c | 0 {third_party => native}/rnnoise/src/pitch.c | 0 {third_party => native}/rnnoise/src/pitch.h | 0 {third_party => native}/rnnoise/src/rnn.c | 0 {third_party => native}/rnnoise/src/rnn.h | 0 .../rnnoise/src/rnnoise_data.c | 0 .../rnnoise/src/rnnoise_data.h | 0 .../rnnoise/src/rnnoise_tables.c | 0 {third_party => native}/rnnoise/src/vec.h | 0 {third_party => native}/rnnoise/src/vec_avx.h | 0 .../rnnoise/src/vec_neon.h | 0 .../rnnoise/src/x86/dnn_x86.h | 0 .../rnnoise/src/x86/x86_arch_macros.h | 0 .../rnnoise/src/x86/x86cpu.h | 0 {core/proto => proto}/voicecat.proto | 0 vcpkg.json | 2 +- 109 files changed, 877 insertions(+), 4981 deletions(-) rename clients/apple/{iOS/VoiceCatiOS => dotnet/VoiceCat.iOS}/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png (100%) rename clients/apple/{iOS/VoiceCatiOS => dotnet/VoiceCat.iOS}/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename clients/apple/{iOS/VoiceCatiOS => dotnet/VoiceCat.iOS}/Assets.xcassets/Contents.json (100%) create mode 100644 docs/ios-deploy.md delete mode 100644 docs/porting-to-dotnet.md create mode 100644 dotnet-tools.json delete mode 100644 dotnet/compare-dsp-fixtures.ps1 delete mode 100644 dotnet/oracle/CMakeLists.txt delete mode 100644 dotnet/oracle/database.cpp delete mode 100644 dotnet/oracle/dsp.cpp delete mode 100644 dotnet/oracle/main.cpp delete mode 100644 dotnet/oracle/passwords.cpp delete mode 100644 dotnet/oracle/tls.cpp delete mode 100644 dotnet/oracle/voice.cpp rename dotnet/tests/VoiceCat.Tests/Fixtures/{cpp-passwords.json => argon2id.json} (100%) rename dotnet/tests/VoiceCat.Tests/Fixtures/{cpp-noise.json => rnnoise.json} (100%) rename dotnet/tests/VoiceCat.Tests/Fixtures/{cpp-wire.json => wire.json} (100%) delete mode 100644 dotnet/tests/VoiceCat.Tests/TlsInteropTests.cs rename {clients/apple/iOS/Shared => native/apple/broadcast}/BroadcastAudioRing.swift (100%) rename {clients/apple/iOS/VoiceCatBroadcast => native/apple/broadcast}/Info.plist (90%) rename {clients/apple/iOS/VoiceCatBroadcast => native/apple/broadcast}/SampleHandler.swift (100%) rename {clients/apple/iOS/VoiceCatBroadcast => native/apple/broadcast}/VoiceCatBroadcast.entitlements (57%) create mode 100644 native/apple/broadcast/VoiceCatBroadcast.xcodeproj/project.pbxproj create mode 100644 native/apple/broadcast/VoiceCatBroadcast.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 native/apple/broadcast/project.yml rename {dotnet/native => native/media}/CMakeLists.txt (98%) rename {dotnet/native => native/media}/NOTICE.txt (100%) rename {dotnet/native => native/media}/media.c (100%) rename {third_party => native}/rnnoise/AUTHORS (100%) rename {third_party => native}/rnnoise/COPYING (100%) rename {third_party => native}/rnnoise/README (100%) rename {third_party => native}/rnnoise/include/rnnoise.h (100%) rename {third_party => native}/rnnoise/src/_kiss_fft_guts.h (100%) rename {third_party => native}/rnnoise/src/arch.h (100%) rename {third_party => native}/rnnoise/src/celt_lpc.c (100%) rename {third_party => native}/rnnoise/src/celt_lpc.h (100%) rename {third_party => native}/rnnoise/src/common.h (100%) rename {third_party => native}/rnnoise/src/cpu_support.h (100%) rename {third_party => native}/rnnoise/src/denoise.c (100%) rename {third_party => native}/rnnoise/src/denoise.h (100%) rename {third_party => native}/rnnoise/src/kiss_fft.c (100%) rename {third_party => native}/rnnoise/src/kiss_fft.h (100%) rename {third_party => native}/rnnoise/src/nnet.c (100%) rename {third_party => native}/rnnoise/src/nnet.h (100%) rename {third_party => native}/rnnoise/src/nnet_arch.h (100%) rename {third_party => native}/rnnoise/src/nnet_default.c (100%) rename {third_party => native}/rnnoise/src/opus_types.h (100%) rename {third_party => native}/rnnoise/src/parse_lpcnet_weights.c (100%) rename {third_party => native}/rnnoise/src/pitch.c (100%) rename {third_party => native}/rnnoise/src/pitch.h (100%) rename {third_party => native}/rnnoise/src/rnn.c (100%) rename {third_party => native}/rnnoise/src/rnn.h (100%) rename {third_party => native}/rnnoise/src/rnnoise_data.c (100%) rename {third_party => native}/rnnoise/src/rnnoise_data.h (100%) rename {third_party => native}/rnnoise/src/rnnoise_tables.c (100%) rename {third_party => native}/rnnoise/src/vec.h (100%) rename {third_party => native}/rnnoise/src/vec_avx.h (100%) rename {third_party => native}/rnnoise/src/vec_neon.h (100%) rename {third_party => native}/rnnoise/src/x86/dnn_x86.h (100%) rename {third_party => native}/rnnoise/src/x86/x86_arch_macros.h (100%) rename {third_party => native}/rnnoise/src/x86/x86cpu.h (100%) rename {core/proto => proto}/voicecat.proto (100%) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 44b37e6..be3dbac 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -2,9 +2,9 @@ name: .NET port on: push: - paths: ['dotnet/**', 'clients/apple/dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml'] + paths: ['dotnet/**', 'clients/windows/**', 'clients/apple/dotnet/**', 'native/**', 'proto/**', 'assets/**', 'Dockerfile', '.github/workflows/dotnet.yml'] pull_request: - paths: ['dotnet/**', 'clients/apple/dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml'] + paths: ['dotnet/**', 'clients/windows/**', 'clients/apple/dotnet/**', 'native/**', 'proto/**', 'assets/**', 'Dockerfile', '.github/workflows/dotnet.yml'] workflow_dispatch: jobs: @@ -50,36 +50,3 @@ jobs: run: dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx - name: Build managed AppKit and UIKit clients run: dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug --no-restore - - cpp-conformance: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: actions/setup-dotnet@v4 - with: - global-json-file: dotnet/global.json - - uses: actions/cache@v4 - with: - path: ~/.cache/vcpkg - key: dotnet-oracle-linux-${{ hashFiles('vcpkg.json', 'vcpkg') }} - - name: Install C++ build dependencies - run: | - sudo apt-get update - sudo apt-get install -y build-essential cmake ninja-build curl zip unzip tar pkg-config autoconf autoconf-archive automake libtool nasm python3 - ./vcpkg/bootstrap-vcpkg.sh -disableMetrics - - name: Build and verify both implementations - run: | - cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON - cmake --build --preset dev - ctest --preset dev - ./build/dev/bin/voicecat-dotnet-oracle build/dev/cpp-wire.json - diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json build/dev/cpp-wire.json - ./build/dev/bin/voicecat-dotnet-password-oracle build/dev/cpp-passwords.json - diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-passwords.json build/dev/cpp-passwords.json - ./build/dev/bin/voicecat-dotnet-dsp-oracle build/dev/cpp-noise.json - pwsh -File dotnet/compare-dsp-fixtures.ps1 dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json build/dev/cpp-noise.json - pwsh -File dotnet/build-native.ps1 - dotnet restore dotnet/VoiceCat.slnx --locked-mode - VOICECAT_TLS_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-tls-oracle" VOICECAT_DATABASE_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-database-oracle" VOICECAT_VOICE_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-voice-oracle" VOICECAT_VCCLI="$PWD/build/dev/bin/vccli" dotnet test dotnet/VoiceCat.slnx -c Release --no-restore diff --git a/AGENTS.md b/AGENTS.md index 0231c32..1bfe552 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,115 +1,54 @@ -# AGENTS.md — working method +# VoiceCat working method -This file is the **working method** for a developer or AI agent picking up VoiceCat. Companion -files: +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. -- [`CLAUDE.md`](CLAUDE.md) — the hub: build/test commands, architecture at a glance, doc map. -- [`PROGRESS.md`](PROGRESS.md) — living tracker: what's done, what's next. **Update it as you work.** -- [`docs/`](docs/) — the **source of truth** for all design. +## Definition of done -Read those, then use the method below. +A clean compile is the floor. Changes are done when their observable behavior is covered by a +test or, for hardware/accessibility/release behavior, by a clearly recorded manual gate. -## What this repo is right now +For implementation work: -A complete **design** ([`docs/`](docs/)) plus a working implementation through M5: real TLS -control plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree, -permissions, moderation, disconnect/keepalive/reaper. The Windows WinForms C# client is -shipped (M4). The macOS/iOS Swift client is next. The `skeleton` preset still links a -no-deps stub path (`VC_ERR_NOT_IMPLEMENTED`) for smoke-check builds. +1. Read the relevant code and current contract document. +2. Add or adjust the smallest behavior test that demonstrates the requested result. +3. Implement the change without weakening encryption, real-time safety, or accessibility. +4. Build and run the affected tests, then the full managed test suite when practical. +5. Update concise current documentation and `PROGRESS.md` if the handoff changed. -## The working method (important) +## Supported layout -**A clean compile is the floor, not the goal.** Do not treat "make the compiler errors go -away" as done. Each milestone in [`docs/roadmap.md`](docs/roadmap.md) has an **exit -criterion stated as observable behavior** — that is what "done" means. Examples: +| 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 | +| `clients/windows/` | Supported WinForms client | +| `clients/apple/dotnet/` | 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 | -- M1 done = *two `vccli` instances actually chat through a real server over TLS*, not "it builds". -- M2 done = *you can talk between two clients in a channel and hear loss concealment work*. - -So the loop is: - -1. Pick the current milestone in `docs/roadmap.md`. Read the relevant design doc section. -2. Write the smallest test (CTest, or a `vccli` interaction) that encodes the exit behavior. -3. Implement the subsystem until that test passes — not just until it compiles. -4. Keep the build green and the existing tests passing on every commit. - -Every commit must compile and pass `ctest`. Behavior tests are how you know you're actually -making progress. - -## Build - -Default development preset (real deps via vcpkg — works on Windows/Linux/macOS). vcpkg is -bundled as a git submodule at `vcpkg/`, pinned to `vcpkg.json`'s `builtin-baseline`: +## Core verification ```bash -git submodule update --init vcpkg # one-time, after cloning -./vcpkg/bootstrap-vcpkg.sh # .bat on Windows -cmake --preset dev -cmake --build --preset dev -ctest --preset dev +./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 ``` -To use an external vcpkg checkout instead, `export VCPKG_ROOT=/path/to/vcpkg` — it always -takes priority over the bundled submodule. +Apple builds additionally use `./dotnet/build-native-ios.sh` and +`clients/apple/dotnet/VoiceCat.Apple.slnx` on macOS. -Skeleton (no third-party deps — works immediately, no vcpkg needed): +## Hard rules -```bash -cmake --preset skeleton -cmake --build --preset skeleton -ctest --preset skeleton -``` - -> **Windows gotcha — always run `ctest` and built binaries via PowerShell, not Git Bash.** -> MinGW-built executables fail in Git Bash with exit code `0xc0000139` -> (STATUS_ENTRYPOINT_NOT_FOUND) even though the file exists and is marked executable. -> PowerShell runs them correctly. Use the PowerShell tool (not Bash) for any `ctest`, -> `voicecat-server.exe`, or `vccli.exe` invocation on Windows. - -Other presets: `release` (optimized + tests), `server-release` (optimized + stripped, -deployment-shaped), `windows-client` (DLL for C# app), `apple-dev`/`apple-ios`/ -`apple-ios-sim` (Apple platform scaffolding). See [`docs/building.md`](docs/building.md) -for the full matrix. - -`vcpkg.json` pins all deps to a fixed vcpkg baseline — `cmake --preset dev` resolves them -automatically on first configure. The vcpkg triplet is auto-resolved from the host platform -by [`cmake/voicecat-toolchain.cmake`](cmake/voicecat-toolchain.cmake), which also resolves -`VCPKG_ROOT` (env var override, else the bundled `vcpkg/` submodule). - -## Where each subsystem lives (and its doc) - -| Path | Subsystem | Design | -|------|-----------|--------| -| `core/include/voicecat.h` | The C ABI every client/server calls | docs/architecture.md §4 | -| `core/proto/voicecat.proto` | Control-plane wire format | docs/protocol.md | -| `core/src/net/` | Asio TCP/UDP transport, framing | docs/architecture.md, docs/protocol.md §1 | -| `core/src/crypto/` | TLS 1.3 (mbedTLS), media AEAD (libsodium), anti-replay | docs/security.md | -| `core/src/codec/` | Opus encode/decode, FEC/DTX | docs/voice.md §3–4 | -| `core/src/protocol/` | Envelope (de)serialize, state machine, routing | docs/protocol.md | -| `core/src/session/` | Channels, users, streams, permissions, text | docs/protocol.md §5 | -| `core/src/audio/` | Capture/playback (miniaudio), APM DSP, jitter buffer, mixer | docs/voice.md §8–11 | -| `server/` | Connection mgr, session registry, SFU relay, SQLite | docs/architecture.md §5 | -| `tools/vccli/` | Headless client to drive/verify the protocol | — | - -## Suggested first steps (M1 spine) - -1. **Wire protobuf + framing** (`net/` + `protocol/`): build, then generate C++ from - `voicecat.proto`, implement the `[u32 length][Envelope]` framing over a plain TCP socket - (TLS can come right after). Test: round-trip an `Envelope` through the framer. -2. **TLS 1.3 via mbedTLS** (`crypto/`): wrap the TCP channel. Test: `vccli` completes a TLS - handshake against `voicecat-server` and exchanges a `ClientHello`/`ServerHello`. -3. **Auth + state** (`session/`): guest + admin-provisioned accounts (Argon2id/SQLite), - channel tree snapshot/deltas, ephemeral text relay. Test: two `vccli` chat. - -Then proceed to M2 (UDP media) per the roadmap. - -## House rules - -- **No GPL/LGPL dependencies, ever** (closed-source redistribution is a goal). See - docs/tech-stack.md §5. CI should fail on a copyleft transitive dep. -- **Encryption is mandatory** — never add a plaintext transport path. docs/security.md. -- **Real-time audio threads** never allocate, lock, or block. docs/architecture.md §3. -- Keep `docs/` and code in sync. If you change a wire format or the C ABI, update the doc in - the same commit. -- The C ABI is the contract for the Swift/C# clients — treat changes to `voicecat.h` and - `voicecat.proto` as deliberate, versioned events (docs/protocol.md §8). +- Encryption is mandatory: TLS control plus authenticated encrypted UDP media. +- Never introduce GPL/LGPL dependencies. +- 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 9ae9318..b3ffa30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,194 +1,53 @@ -# CLAUDE.md — agent hub for VoiceCat +# VoiceCat developer guide -Auto-loaded each session. This is the **map**: build commands, architecture at a glance, and -where everything is. For the *working method* read [`AGENTS.md`](AGENTS.md); for *what's done -and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/). +VoiceCat is a self-hosted encrypted voice and text chat system. The supported implementation +is .NET 10. Read `AGENTS.md` for working rules and `PROGRESS.md` for the current short status. -> **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move, -> server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows -> WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj` -> at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at -> `clients/apple/iOS/`. `ctest --preset dev` green — 29/29 tests. -> External PCM feed/tap API (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) shipped. -> **Screen-audio sharing shipped on macOS (ScreenCaptureKit) and iOS (ReplayKit Broadcast -> Upload Extension → host App Group ring → `vc_stream_feed_pcm`).** -> The managed C# AppKit replacement now implements the Swift macOS feature surface and has a -> validated ad-hoc Release bundle. Swift remains the release oracle until VoiceOver, live-call, -> and credentialed notarization gates pass; see `clients/apple/dotnet/README.md`. -> **Noise suppression shipped (RNNoise, vendored at `third_party/rnnoise/`)** — both send-side -> mic NR (`vc_set_input_noise_reduction`) and per-listener receive NR; client on/off toggles ship -on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23). -> See [`PROGRESS.md`](PROGRESS.md). +## Build and test -VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). TLS over TCP (control) -+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives -native clients (Swift on macOS/iOS, C# on Windows) and the server. - ---- - -## Build & test commands - -The .NET rewrite lives under `dotnet/`. Build and test its wire/crypto, TLS, codec/DSP, and managed control/UDP server slices -alongside the existing C++ tree: - -```powershell -./dotnet/build-native.ps1 # CMake + C compiler; pinned Opus with DRED + RNNoise +```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 ``` -See `dotnet/README.md` for C# conventions and required native voice/CLI conformance, -and `docs/api-dotnet.md` for managed interfaces. The managed server, CLI, audio/client core, -Windows cutover, and macOS functional surface are implemented. The Windows and macOS apps -contain only the permitted media shim. Accessibility/manual endurance validation, macOS -notarization, the iOS rewrite, and broad server deployment work remain. - -The default development preset is **`dev`** — it builds everything (server + tools + tests) -with real vcpkg deps. The `skeleton` preset (no deps, stubs only) is a fast smoke check; see -[`docs/building.md`](docs/building.md) for the full preset matrix. +Apple client builds require macOS, Xcode, and the .NET macOS/iOS workloads: ```bash -# Configure + build (default development preset; needs VCPKG_ROOT) -cmake --preset dev -cmake --build --preset dev - -# Run the tests (21 behavior tests — grows per milestone) -# NOTE on Windows: run ctest via PowerShell, NOT Git Bash — MinGW binaries fail in Git Bash -# with exit 0xc0000139 (STATUS_ENTRYPOINT_NOT_FOUND). PowerShell runs them correctly. -ctest --preset dev # or: ctest --test-dir build/dev --output-on-failure - -# Run the binaries — same Windows rule: use PowerShell, not Git Bash -./build/dev/bin/vccli # headless test client -./build/dev/bin/voicecat-server --help -./build/dev/bin/voicecat-server --name "My Server" - -# Build a single target / be verbose -cmake --build --preset dev --target vccli -cmake --build --preset dev --verbose - -# Clean -rm -rf build/dev # nuke; or: -cmake --build --preset dev --target clean +./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 ``` -Other presets (see [`docs/building.md`](docs/building.md) for full detail): +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. -```bash -cmake --preset skeleton # no-deps stub smoke (no VCPKG_ROOT needed) — 2 tests -cmake --preset release # optimized + tests on, symbols kept (profile/debug-friendly) -cmake --preset server-release # optimized + stripped, no tests (deployment-shaped) -cmake --preset windows-client # voicecat.dll for the C# WinForms client (Windows only) -cmake --preset apple-dev # libvoicecat.a for macOS Swift Package (scaffolding, macOS only) -``` +## Current architecture -Vcpkg triplet is auto-resolved from the host platform by -[`cmake/voicecat-toolchain.cmake`](cmake/voicecat-toolchain.cmake) — `x64-mingw-static` on -Windows, `x64-linux` on Linux, `arm64-osx` on Apple Silicon. See docs/building.md §1 -"Platform matrix" for details. +- `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. +- `clients/windows` is the WinForms client. +- `clients/apple/dotnet` 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. -vcpkg is bundled as a git submodule at `vcpkg/`, pinned to the commit in `vcpkg.json`'s -`builtin-baseline`. One-time setup after cloning: +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. -```bash -git submodule update --init vcpkg -./vcpkg/bootstrap-vcpkg.sh # .bat on Windows -``` +## Invariants -To use an external vcpkg checkout instead (e.g. one shared across projects), set -`VCPKG_ROOT` — it always takes priority over the bundled submodule: - -```bash -export VCPKG_ROOT=/path/to/vcpkg # works on Linux / macOS / Windows -``` - -Other useful toggles (pass with `-D` at configure time): - -```bash -cmake --preset dev -DVOICECAT_BUILD_SHARED=ON # build libvoicecat as a .dll/.so/.dylib (for the C# client) -cmake --preset dev -DVOICECAT_BUILD_SERVER=OFF # core + tools only -cmake --preset dev -DVOICECAT_BUILD_TESTS=OFF -``` - -Formatting: `clang-format` config is `.clang-format` (Google base, 100 cols, 4-space). - -```bash -git ls-files '*.cpp' '*.h' | xargs clang-format -i -``` - ---- - -## Architecture at a glance - -Full detail: [`docs/architecture.md`](docs/architecture.md). The short version: - -``` - Swift (macOS/iOS) ─┐ ┌─ C# (Windows) - ├──▶ libvoicecat (C ABI: voicecat.h) ◀──┤ - voicecat-server ───┘ net · crypto · codec · protocol · └─ all UIs are thin - (links core) session · audio the core owns audio -``` - -- **One core, many faces.** Protocol, Opus, crypto, networking, jitter buffer, and mixing - live once in C++. Clients call the **C ABI** (`core/include/voicecat.h`); the server links - the same core, so framing/crypto never drift between ends. -- **Two transports.** TCP + **TLS 1.3** (control, protobuf `Envelope`) and UDP + **exported-key - ChaCha20-Poly1305 AEAD** (media, fixed binary voice frame). Encryption is mandatory. -- **Threading.** Real-time audio threads never allocate/lock/block; they exchange data with - the net thread via lock-free ring buffers; a worker pool absorbs blocking work. - -### Subsystem map (code ↔ design doc) - -| Path | Subsystem | Design | -|------|-----------|--------| -| `core/include/voicecat.h` | The C ABI (client/server contract) | architecture.md §4 | -| `core/proto/voicecat.proto` | Control-plane wire format (source of truth) | protocol.md | -| `core/src/net/` | Asio TCP/UDP transport, `[u32 len][payload]` framing | protocol.md §1, voice.md §2 | -| `core/src/crypto/` | TLS 1.3 (mbedTLS), media AEAD (libsodium), anti-replay | security.md | -| `core/src/codec/` | Opus encode/decode, FEC/DTX | voice.md §3–4 | -| `core/src/protocol/` | Envelope (de)serialize, request/response, dispatch | protocol.md | -| `core/src/session/` | Channels, users, streams, permissions, ephemeral text | protocol.md §5 | -| `core/src/audio/` | miniaudio I/O, APM DSP, jitter buffer, mixer | voice.md §8–11 | -| `core/src/core/` | `vc_client` — the handle behind the C ABI | architecture.md §4 | -| `server/` | Connection mgr, session registry, SFU relay, SQLite | architecture.md §5 | -| `tools/vccli/` | Headless client that drives/verifies the protocol | — | -| `clients/apple/`, `clients/windows/` | Native GUIs (M4) | architecture.md §4 | - ---- - -## Documentation index (source of truth) - -Read [`docs/`](docs/) before changing behavior. Order: - -1. [docs/README.md](docs/README.md) — overview, locked decisions, glossary -2. [docs/architecture.md](docs/architecture.md) — core, C ABI, threading, server -3. [docs/protocol.md](docs/protocol.md) — control plane, Envelope, message catalog -4. [docs/voice.md](docs/voice.md) — UDP media, Opus, multi-stream, two-sided NR, VAD/PTT -5. [docs/security.md](docs/security.md) — mandatory encryption, TLS+AEAD, accounts, threat model -6. [docs/tech-stack.md](docs/tech-stack.md) — libraries, permissive-license rule, tooling -7. [docs/deployment.md](docs/deployment.md) — zero-config self-host (Docker / binary / source) -8. [docs/roadmap.md](docs/roadmap.md) — milestones + resolved decisions -9. [docs/building.md](docs/building.md) — what each CMake preset is for + manual server/`vccli` testing - ---- - -## Keeping track of progress - -**[`PROGRESS.md`](PROGRESS.md) is the living status file.** When you finish a task, check it -off there and note the next step, so the next agent can pick up instantly. Treat it as part of -the work, not an afterthought — update it in the same commit as the code. - ---- - -## House rules (hard constraints) - -- **A clean compile is the floor, not the goal.** "Done" = the milestone's observable exit - criterion in [`docs/roadmap.md`](docs/roadmap.md) passes (e.g. M1 = two `vccli` actually chat - over TLS). Encode it as a test. See [`AGENTS.md`](AGENTS.md). -- **No GPL/LGPL dependencies, ever** (closed-source redistribution is a goal). docs/tech-stack.md §5. -- **Encryption is mandatory** — never add a plaintext transport path. docs/security.md. -- **Real-time audio threads** never allocate, lock, or block. docs/architecture.md §3. -- **Keep docs + code in sync.** Changing a wire format (`voicecat.proto`) or the C ABI - (`voicecat.h`) is a deliberate, versioned act — update the doc in the same commit (protocol.md §8). -- Every commit must build (`cmake --build --preset dev`) and pass `ctest --preset dev`. +- TLS control and encrypted UDP media are mandatory; do not add plaintext transports. +- No GPL or LGPL dependencies. +- Audio callbacks must not allocate, lock, block, or perform network I/O. +- Treat `proto/voicecat.proto`, persisted database formats, and the ReplayKit ring layout as + explicit versioned contracts. +- A passing build is not enough: add or update behavior tests for observable changes. +- Keep `PROGRESS.md` concise; do not append a historical changelog. diff --git a/CMakeLists.txt b/CMakeLists.txt index b5affa6..056f293 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,7 +3,7 @@ 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 vendored RNNoise noise-suppression lib (third_party/rnnoise). + # 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 @@ -51,12 +51,7 @@ add_subdirectory(core) option(VOICECAT_BUILD_DOTNET_NATIVE "Build native codec/DSP bindings for the .NET rewrite" OFF) if(VOICECAT_BUILD_DOTNET_NATIVE) - add_subdirectory(dotnet/native) -endif() - -option(VOICECAT_BUILD_DOTNET_ORACLE "Build the .NET port conformance fixture generator" OFF) -if(VOICECAT_BUILD_DOTNET_ORACLE) - add_subdirectory(dotnet/oracle) + add_subdirectory(native/media) endif() if(VOICECAT_BUILD_SERVER) diff --git a/Dockerfile b/Dockerfile index 07b0188..6e85b51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0.203-noble AS build WORKDIR /src COPY global.json Directory.Build.props Directory.Build.targets ./ -COPY core/proto/voicecat.proto core/proto/voicecat.proto +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 \ diff --git a/PROGRESS.md b/PROGRESS.md index b1d0ee0..e2f8a57 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,1590 +1,41 @@ -# PROGRESS — VoiceCat +# VoiceCat status -Living status. **Update this file in the same commit as your work** so the next agent picks -up instantly. Newest status at the top. +Updated: 2026-09-19 -- **Date convention:** ISO (YYYY-MM-DD). -- Statuses: `[ ]` not started · `[~]` in progress · `[x]` done. +## 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. -## ▶ Where we left off / next action +The supported source-of-truth layout is: -- **In progress (2026-09-19): managed iOS/UIKit replacement at feature parity.** Chose native UIKit over MAUI - to preserve direct AVAudioSession/AVAudioEngine control and native VoiceOver semantics. - Added the .NET 10 iOS application, App Group profile/TOFU/Keychain migration, saved-server - and connected channel/chat/settings flows, managed client event/reconnect handling, and - foreground microphone/playback through an allocation-free callback and bounded PCM rings. - The Opus/RNNoise shim now cross-compiles as merged static device and simulator archives and - binds through `__Internal`. The Swift ReplayKit upload extension is retained, its ring ABI - is documented/versioned, and the managed pump drains it into a screen-audio stream; MSBuild - builds and embeds the appex. Added hierarchical protected-channel management, full channel - codec editing, roster moderation and per-user receive tuning, permissions, account - administration, persistent VAD/PTT/always-on voice controls, event feedback, advanced iOS - audio routing, reconnect restoration, and an always-visible voice bar. iOS 27 uses a small - dynamically loaded ScreenCaptureKit audio bridge; iOS 18–26 retain ReplayKit, with both producers - feeding the frozen ring ABI. Scene lifecycle handling now preserves the active PlayAndRecord - graph in the background and recovers it on foreground activation, interruption, hardware-route - changes and media-service resets. The iOS 27 Settings action now switches ScreenCaptureKit audio - on and off, including cancellation of stale stream negotiation after rapid switching; the native - bridge also tears down its picker observer and active state. Added physical-device build/deploy - wrappers and expanded the managed administration round-trip test. Corrected the historical App Group and extension - bundle IDs. CI installs the iOS workload and builds both managed Apple clients. **Next:** run - the physical-device VoiceOver, route-change, background/lock and real multi-human call matrix; - retain the Swift app as release oracle until those observable gates pass. +- `proto/voicecat.proto` — wire schema. +- `dotnet/src/` — protocol, crypto, codec/DSP bindings, server, client core, audio, and CLI. +- `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. +- `native/apple/broadcast/` — required ReplayKit upload extension and shared ring producer. -- **In progress (2026-09-19): managed macOS functional-parity checkpoint.** Extended the - .NET AppKit client across the remaining Swift desktop surface: persistent audio and - notification settings; VAD, focus-scoped configurable PTT and always-on modes; input, - output and auxiliary-device selection; input/output/auxiliary gain; stereo microphone; - RNNoise; self mute/deafen; event sounds and speech; modeless private conversations; - speaking and mute state; moderation, permissions, channel configuration and account - administration. Added typed administration helpers to `VoiceCat.Core` and per-local-stream - gain to `VoiceCat.Audio`. ScreenCaptureKit now publishes desktop audio with entire-desktop, - application-only and application-exclusion scopes, including optional VoiceOver/speech - exclusion, without introducing a native protocol dependency. The managed client imports - Swift profiles, TOFU pins and Keychain passwords, preserving a one-time JSON backup when it - converts profile storage. Added a Release publishing script with an ad-hoc validation path - and Developer ID/notarization path; consistently re-signing the nested .NET runtime fixed a - hardened-runtime Team-ID launch failure. Signing now operates on a separate distribution - copy so it cannot corrupt MSBuild's incremental app bundle or signing cache. **Verified:** - the ad-hoc Release app passes strict deep signature verification and remains running after - launch; Debug and Release Apple builds - succeed; 190/198 managed tests pass with eight optional native/published-server oracles - skipped; native `dev` CTest passes 29/29. The administration protocol test exercises typed - result round trips and the profile test proves Swift import plus backup. **Next:** manually - exercise ScreenCaptureKit selection and a real multi-human call, complete the VoiceOver - navigation/announcement matrix, and run the credentialed Developer ID notarization path. - Keep the Swift macOS app as the release oracle until all three gates pass; only then remove - its replaceable app/UI code. +## Release gates -- **In progress (2026-09-18): managed macOS client and selectable audio path.** Added a - separate .NET 10 AppKit solution with application/menu lifecycle, guest/account connection, - explicit TOFU approval, channel selection, roster/chat, disconnect state, accessibility - labels, sandbox entitlements and deterministic disposal. Voice join now opens bounded - stereo playback, subscribes, announces a managed microphone stream, converts the selected - Core Audio input to interleaved 48 kHz int16 and feeds `VoiceCat.Audio`; leave/disconnect - tears the path down. Channel changes suspend and resume voice so streams renegotiate. - Playback uses `AVAudioSourceNode` plus the shared tested `PcmRing`, converting to Core Audio's - native planar Float32 layout; the render callback allocates no managed memory and never locks - or blocks. Added a - macOS CI gate for the real workload, native Opus/RNNoise shim and Apple solution. Local - server profiles now persist validated host, port, authentication mode and identity through - `VoiceCat.Core`; malformed profile files do not prevent startup, writes replace atomically, - and passwords are deliberately excluded from the model and JSON. Three behavior tests cover - profile round trips, corrupt input and account validation. Protected channels are labeled - and prompt for a session-only password before interrupting active voice; cancel and rejected - joins restore the selected channel and voice session. Saved account profiles can opt into a - macOS Keychain generic-password item keyed by profile ID; secrets are written only after - successful authentication and removed when remembering is disabled or the profile is deleted. - The composer can now target the current channel or any connected user, and labels private - messages with the other participant. - On Apple Silicon macOS 27, SDK/workload set 10.0.401 now builds against the explicit - `net10.0-macos27.0` preview target. The build selects native arm64 Homebrew `protoc`, - deduplicates the media dylib during app bundling and uses macOS 27's error-returning - AVFoundation overloads while preserving macOS 14–26 calls. Debug disables hardened runtime - so its ad-hoc-signed .NET dylibs load without a developer identity; Release remains hardened. - The arm64 bundle is valid, launches successfully and displays the connection window on a - physical macOS 27 host. The app now creates missing private-file parent directories on first - launch, requests microphone authorization explicitly, and enumerates/selects actual Core Audio - input and output devices; changing either device while joined restarts voice cleanly. Native - validation on macOS also exposed a PCM-tap teardown race: - disabling either external sink is now a quiescence barrier, so callback state can be released - safely without adding locks or waits to the real-time path. **Verified:** managed locked - restore and warning-free Release build; 188/196 managed tests passed with eight optional - native/published-server oracle tests skipped; native `dev` build and 29/29 CTest tests passed; - both PCM teardown regressions plus external playback passed five consecutive runs. Live testing - selected the internal MacBook microphone, proved nonzero mic energy at a peer, and played a - clean sustained peer tone in the DTX-disabled Music Room. Lobby's later hiss was confirmed as - expected Opus DTX comfort noise for a synthetic sine, not playback corruption. Per user - direction, final listen quality will be exercised in real multi-human calls after feature - completion rather than an artificial ten-minute tone. This checkpoint is superseded by the - 2026-09-19 functional-parity entry above. +- Run real multi-person calls on Windows, macOS, and physical iOS hardware. +- Complete NVDA and VoiceOver navigation/announcement passes. +- Exercise iOS background/lock, interruption, Bluetooth, route-change, ReplayKit, and iOS 27 + ScreenCaptureKit paths on devices. +- Complete Developer ID signing/notarization and iOS distribution signing. +- Run the published Linux container and a 30-minute-or-longer server soak. -- **Done (2026-09-16): Linux production packaging checkpoint.** Added a real TLS 1.3 - `--health-check` with optional certificate pin verification. Linux x64 now has separate - locked self-contained publish graphs and invariant-globalization startup without a system - ICU dependency. Replaced the native Docker build with a non-root managed multi-stage build - on Microsoft's chiseled runtime-dependencies image; Compose uses a read-only root, - capability drop and no-new-privileges. Added a hardened DynamicUser systemd unit, install - helper, Linux smoke and reusable concurrent text/voice soak driver. **Verified:** the - published ELF starts in WSL, completes TLS health with its emitted pin, creates mode-0700 - state, and exits cleanly on SIGTERM; `systemd-analyze verify` accepts the unit. The - published Windows server completed four soak cycles / 16 independent client sessions. - Dockerfile execution could not be checked because the local Docker daemon is unavailable. - **Next:** run the 30-minute+ soak and container build in release infrastructure, publish/ - sign the image, and perform Windows NVDA/listen gates. Then begin the C# AppKit client port. +## Cleanup in progress -- **Done (2026-09-16): Managed CLI and cross-generation client exit.** Added `VoiceCat.Cli` - with interactive channel text, TOFU, channel selection and deterministic headless - text/tone verification. Independent process tests run two managed CLIs in channels 1 and 2 - and prove text plus decoded bidirectional voice. A separate process test pairs it with the - existing C++ `vccli` in channel 2 and verifies text and voice in both directions. This - completes the behavioral Phase 6 criterion. **Verified:** 193 managed tests with all - conformance variables enabled, locked restore, package audit and 29/29 CTest tests. - **Next:** resume broad server production - deployment (Linux publish/container/service and soak), then the remaining manual Windows - listen/NVDA release checks before beginning the C# AppKit port. +- 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. -- **Done (2026-09-16): Managed client/audio and Windows cutover checkpoint.** Added - `VoiceCat.Core` with TOFU-gated TLS, correlated concurrent requests, immutable snapshots, - reconnects, bounded client events, UDP binding and authenticated encrypted media. Added - `VoiceCat.Audio` with allocation-free real-time cycles, bounded PCM/jitter queues, - 5/10/20/40/60 ms Opus reframing, VAD/PTT, DTX, DRED → FEC → PLC, RNNoise, per-stream - controls and stereo mixing. Shared TLS/media primitives moved to `VoiceCat.Crypto`. - The WinForms app now references a managed compatibility facade over these libraries and - uses C# WASAPI capture, loopback and playback. Active streams renegotiate after channel - moves while capture-facing IDs stay stable. Per-app mixing no longer allocates or locks in - its real-time loop. The self-contained Windows publish includes only the permitted - `voicecat_media.dll`; a real default-device capture/playback plus main-form smoke passed. - Managed tests cover two-way decoded PCM voice, text, TOFU, concurrent requests, reconnects, - bounded jitter, zero-allocation cycles, recovery order and stream continuity across a - mono→stereo channel move. The old Interop project remains only as a migration oracle and - its nine native ABI tests remain green. **Verified:** 190 managed tests with every native - conformance/published-server variable enabled, nine Windows native-oracle tests, 29 CTest - tests, locked restores, both self-contained publishes and the permissive license audit. - **Next:** add the managed CLI and explicit managed - client ↔ C++ CLI conversation, then finish production deployment work. The Phase 5/7 - manual ten-minute listen and NVDA gates are not yet signed off; Apple rewrites follow. +## Working rule -- **Done (2026-09-15): Server deployment hardening checkpoint.** Pushed existing work - through `653131b` to `origin/dotnet/foundations`. Added CLI/environment configuration, - all-interface port 8384 defaults, config/fingerprint commands, local administrator - provisioning with hidden/stdin/environment password input, JSON readiness, an exclusive - instance lock, transport-failure observation and ten-second graceful shutdown. - Password-attempt buckets are shared by IP/account across reconnects, bounded to 4096 - keys and enforce configurable burst/refill plus escalating backoff before Argon2. - Added self-contained win-x64 publishing with separate checked runtime lock files; - server publishes without the audio/codec shim. Behavior tests verify precedence, - invalid input, cross-connection/account throttling, readiness, duplicate-instance refusal, - active TLS shutdown and real published-executable provisioning/fingerprint persistence. - **Verified:** 174 managed tests with all conformance/published-server variables enabled; - warning-free Release build, native build/29 CTest tests and permissive package audit. - **Next (user priority):** managed client-core and audio prerequisites, then Windows - WinForms cutover before either Apple GUI. Windows is already C# at the UI layer but - still depends on native protocol/audio. Linux publishing/container/service packaging - and operational soak remain before broad production rollout; do not claim Phase 4 - production rollout or GUI parity complete without those platform/behavior checks. - -- **Done (2026-09-15): Managed channel and administration checkpoint.** Reaper committed - as `274b850`. Added protected joins and capacity checks, leave-to-Lobby, persisted channel - create/edit/delete with events, parent/cycle validation and Lobby protection. Native - salted BLAKE2b channel hashes work in both directions; empty edit passwords preserve - protection. Channel edits, moves and deletion clear streams before further media routing. - Session permissions gate kick/ban/move/server-mute/deafen and account create/reset/delete/ - list. Only administrators grant permissions; temporary-channel permission cannot create - permanent channels. Kick/ban retire media and send one reason-bearing LEFT. Account bans - persist by username, guest bans by address; Unix-millisecond expiry converts to database - seconds, fixing the native handler's unit mismatch. Bounded Argon2 work remains outside - the session lock; account lists exclude hashes and respect the frame limit. The existing - C++ CLI successfully creates protected channels and creates/lists accounts against the - managed server. Fixed its temporary channel-string pointers and zero audio defaults. - A native sample-rate regression intermittently measured host microphone audio alongside - its injected tone; changed that test to external capture/playback and kept callback state - alive through client shutdown, with synchronized energy reads. - **Verified:** 169/169 managed tests with all native conformance enabled, zero skips; - warning-free managed Release build, native dev build and 29/29 CTest tests; diff check. - **Next:** production configuration, administrator provisioning/publishing and remaining - server readiness checks (including auth rate limiting). Phase 4 is still in progress. - Then managed audio/core/CLI, Windows cutover, C# AppKit and UIKit clients; preserve Swift - ReplayKit extension and freeze the shared-ring contract before the iOS cutover. - -- **Done (2026-09-15): Managed media-aware reaper.** Voice checkpoint committed as - `05eacb3`. Added `VoiceServerOptions` (name/guests/capacity, handshake deadline, - idle timeout and sweep interval), preserving the previous constructor overload. - Default expiry/sweep are 45 s / 15 s; zero idle timeout disables reaping. Parsed TCP - envelopes, authenticated owned-stream voice and exact bound-endpoint UDP keepalives - refresh one monotonic session timestamp. Invalid media does not refresh it. Removed - the independent 60-second TCP-only timeout so media-active sessions remain connected. - Reaping sends a fatal disconnect and retires presence/media routing with one LEFT - event. Shutdown awaits active control/media/reaper loops and unfinished handshakes. - Tests inject `TimeProvider` timestamps to verify TCP/UDP activity, rejected media, - single departure events, disabled reaping and shutdown. **Verified:** 160/160 managed - tests with all native conformance enabled; managed Release build has zero warnings; - native dev build and 29/29 CTest tests green; `git diff --check` passes. - **Next:** protected channel joins and channel CRUD, permissions/moderation/account - administration, then production configuration/publishing. Phase 4 remains in progress. - Follow with audio/core/managed CLI, switch Windows to the managed library, then C# - AppKit/UIKit clients. Keep the Swift broadcast extension and frozen shared-ring boundary. - -- **Done (2026-09-15): Phase 4 encrypted voice checkpoint.** Existing pending codec/DSP - and initial server work committed as `4067bab`. Managed server now binds UDP on the - TCP port number, issues 16-byte session tokens, supports subscription and multi-stream - signaling, and authenticates/reseals encoded Opus to eligible channel subscribers. - Crypto and endpoints have one UDP-loop owner; control handlers publish immutable - routing snapshots. Rejects invalid tokens, malformed/forged/replayed media and SSRCs - not owned by the sender. Stop, unsubscribe, channel movement and disconnect update - routing; retired keys are cleared without requiring subsequent UDP traffic. - First endpoint binding is fixed for the session (reconnect to change it), unlike - the C++ oracle's permissive rebinding policy. Packet formats/protocol v2 are unchanged. - Two actual C++ `vccli` processes authenticate, join, chat and exchange mono/stereo - voice through the managed server. Native voice oracle additionally verifies three - concurrent streams with bidirectional decoded PCM energy/metadata, without hardware. - Added finite `vccli --test-tone-ms` and fixed normal `--voice` to subscribe first. - **Verified:** 154/154 managed tests, no skips with TLS/database/voice/CLI variables; - native dev build and 29/29 CTest tests; independent native media staging; warning-free - managed Release build; identical regenerated wire/password fixtures; C++ DSP within - one PCM unit; 22 permissive package licenses; `git diff --check` passes. Fan-out core - allocates zero managed bytes for 50 subscribers; transport scheduling and crypto - fallback are excluded. Transport test delivers all 2,500 packets at a paced 50 pps. - **Next:** media-aware keepalive/reaper, then protected channel joins, channel CRUD, - permissions/moderation/account administration and production configuration. Phase 4 - remains in progress. Audio, managed client/CLI, Windows cutover and C# AppKit/UIKit - follow; keep Swift ReplayKit extension and freeze its ring contract before iOS. - -- **In progress (2026-09-15): Phase 4 managed server control plane.** Added TLS socket - orchestration, bounded framing/queues, guest and password authentication, persisted - channels, state snapshots, channel joins, text routing, ping and disconnect events. - The existing C++ CLI authenticates and sends text through the managed server. - Managed Argon2id verification passes libsodium fixtures, including UTF-8 and embedded - NUL passwords. The C++ database oracle proves existing account/channel import and - C++ verification of managed-created accounts without password resets. **Verified:** - 142/142 managed tests with all native interoperability checks enabled, warning-free - Release build, regenerated password fixtures identical, and 22 permissive package - licenses; native dev build and 29/29 CTest tests green. Locked restore passes. - CI requires CLI/database checks in its C++ conformance job. Codec/DSP and the first - server slice are committed together on `dotnet/foundations` as a validated checkpoint. - **Next:** encrypted UDP binding/SFU relay and stream signaling. - UDP voice, streams, administration, protected channel joins and production configuration - remain pending; this is the first control-plane checkpoint, not Phase 4 completion. - -### .NET control-plane checkpoint handoff / discoveries (2026-09-15) - -- **Working tree:** stay on `dotnet/foundations`, tracking `origin/dotnet/foundations`. - Foundation `b76181d` and TLS checkpoint `2df79cd` were committed and pushed. - The codec/DSP port and first managed server checkpoint were subsequently committed - together, including new projects, native bindings/oracles, tests and docs. - See the latest checkpoint commit; no push is requested for this session. -- **Style/scope:** write idiomatic .NET in `dotnet/`; do not copy C++ code or comment - style. The existing implementation is the behavior/wire oracle. No wire changes - were made. Read `docs/porting-to-dotnet.md`, `docs/api-dotnet.md`, `dotnet/README.md` - and the relevant protocol/security/voice sections before the next subsystem. -- **Implemented projects:** `VoiceCat.Protocol` (existing protobuf + framing), - `VoiceCat.Crypto` (media crypto/replay, TLS/exporters, identity/TOFU, password hashing), - `VoiceCat.Codec` (Opus/PLC/DRED), `VoiceCat.Dsp` (RNNoise/energy VAD), and - `VoiceCat.Server` (real TLS control server + compatible SQLite account/channel store). - `dotnet/oracle/` contains optional native wire, TLS, DSP, password and database - conformance executables. `ServerTests` exercises real sockets and the existing CLI; - `AccountStoreTests` proves native database import and password verification both ways. -- **TLS discovery:** BouncyCastle destroys exporter secrets after its handshake - callback. Export keys inside `NotifyHandshakeComplete`, not after the socket loop - notices readiness. Preserve label `voicecat media v1` and contexts `[0]` / `[1]`. - A `TlsSession` has one owner; the control connection loop owns all TLS calls. - Certificate acceptance is a synchronous leaf-SHA256 pin gate, not normal PKI. - New certificates carry the Ed25519 public key in their SAN, but verification of - the ServerHello identity against that SAN remains pending. Partial credential - sets must fail rather than silently generate a new server identity. -- **Native codec discoveries:** the actual pinned Opus is **1.5.2**, despite older - design comments referring to 1.6. Standalone builds use checksum-pinned upstream - sources with DRED/Deep PLC enabled. DRED needs a **30 ms minimum** in this release; - 20 ms produces no redundancy. DRED encoding at 8/12 kHz is explicitly unsupported; - decoding works at all five rates. Recovery offset defaults to one missing frame's - samples before the next packet's start (the older C++ zero offset is not a guide). - Fixed-signature C wrappers avoid the Apple ARM64 varargs ABI issue with Opus CTLs. - Windows DLL staging must omit the MinGW `lib` prefix. MinGW and MSVC builds pass; - iOS needs later static packaging. Device audio callbacks/rings are not implemented. -- **DSP behavior:** RNNoise processes complete 480-sample mono chunks at 48 kHz; - other rates pass through, and partial chunks at 48 kHz are rejected. Native C++ - conformance allows one PCM unit for rounding. VAD hang time uses monotonic - `TimeProvider` timestamps, starts closed and does not replace noise suppression. - The combined allocation test proves zero managed allocations across 1,000 cycles. -- **Password/database discoveries:** use the existing BouncyCastle Argon2 engine - with strict libsodium PHC parsing; no additional Konscious dependency or password - reset is needed. Keep UTF-8 bytes unchanged, including embedded NUL. New hashes use - Argon2id v19, 64 MiB, two iterations, parallelism one, salt 16/output 32 bytes. - Verification is bounded to 128 MiB, ten iterations, parallelism four and 1024 UTF-8 - password bytes; excessive imported costs fail closed. Two per-store password - workers bound CPU/memory use. Failed login does not update `last_login`. - Keep SQLite schema v2; accept v1 migration and reject unknown versions. - **Seed both default channels only when the entire channel table is empty**; - an existing single Lobby is an intentional configuration and must be preserved. -- **SQLite dependency discovery:** the initial `Microsoft.Data.Sqlite` 10.0.5 bundle - pulled an older vulnerable SQLite native dependency, rejected by warnings-as-errors - restore. The implementation uses `Microsoft.Data.Sqlite.Core` 10.0.5, - SQLitePCLRaw bundle 3.0.2 and explicitly pinned SourceGear SQLite 3.50.4.2 instead. - SourceGear's native package lacks a NuGet license expression; the audit has an - exact-version/repository-identity exception for its public-domain SQLite build. - NativeAOT publishing/trimming has not been verified for this solution. -- **Previous control-plane checkpoint limits:** CLI binds loopback; positional arguments are data - directory and TCP port. Guests are enabled there, and the hosting API can disable - them. Accounts can be provisioned through `AccountStore` or native administration; - automatic bootstrap/admin CLI is pending. Authentication enters unprotected Lobby - id 1 subject to capacity. Server owns text sender ids/timestamps. Connections cap - at 64; queues cap at 32 incoming/64 outgoing envelopes, payloads at 64 KiB (shared - framer allows 16 MiB). Slow consumers disconnect. TLS handshake timeout is 15 s; - receive-idle timeout after handshake is 60 s. No UDP port/media features are - advertised, and voice subscription fails explicitly. Protected joins, channel CRUD, - streams, SFU, moderation/admin handlers, configuration compatibility and full reaper - behavior remain pending. **Do not mark Phase 4 or voice interoperability complete.** - -To reproduce the last successful validation on Windows, run in **PowerShell**: - -```powershell -./dotnet/build-native.ps1 -Generator Ninja -CCompiler C:/tools/msys64/ucrt64/bin/cc.exe -cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON -cmake --build --preset dev -ctest --preset dev -dotnet restore dotnet/VoiceCat.slnx --locked-mode -$env:VOICECAT_TLS_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-tls-oracle.exe).Path -$env:VOICECAT_DATABASE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-database-oracle.exe).Path -$env:VOICECAT_VCCLI = (Resolve-Path build/dev/bin/vccli.exe).Path -$env:VOICECAT_VOICE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-voice-oracle.exe).Path -dotnet test dotnet/VoiceCat.slnx -c Release --no-restore -./dotnet/check-licenses.ps1 -``` - -Last results: **160/160 managed tests, no skips with those variables set; 29/29 native -CTest tests; warning-free Release build; 22 package licenses approved; locked restore -and `git diff --check` passed.** Without the variables, native interoperability tests -skip; that is not equivalent verification. Desktop CI stages codec/DSP bindings on -Windows/Linux/macOS. Its Linux C++ job requires TLS, CLI and database interoperability -and regenerates wire/password/DSP fixtures. Only Windows was run locally this session. - -**Voice behavior now verified:** real clients bind UDP and exchange encrypted Opus, -preserving SSRC/timestamp/flags while resealing with recipient-specific counters. -Never decode audio on the SFU. The two-C++-client voice/text criterion passes; -the remaining Phase 4 server behaviors still need implementation and conformance tests. - -- **Done (2026-09-15): Codec/DSP desktop port.** TLS checkpoint `2df79cd` committed - and pushed to `origin/dotnet/foundations`. Added span-based Opus wrappers, safe native - handle ownership, RNNoise processing, monotonic energy VAD, and fixed-signature - native bindings. Round-trip/PLC behavior passes across 40 supported formats with - the existing Opus build. Independent native staging builds checksum-pinned upstream - Opus 1.5.2 with DRED enabled and the existing RNNoise model. Actual dropped-frame - DRED recovery passes across 40 decoder formats; DRED encoding at 8/12 kHz is - explicitly rejected (tests encode those packets at 16 kHz). This release requires - a 30 ms redundancy floor; the older 20 ms setting emits no DRED. C++ denoising - conformance is within one PCM unit, and 1,000 codec/DSP cycles allocate zero managed - bytes. **Verified:** Release build with no warnings; 127/127 managed tests with - native TLS interoperability enabled; native dev build and 29/29 CTest tests green; - 16 permissive NuGet licenses. MinGW and Visual Studio native builds pass. Desktop - CI now builds/stages the bindings before testing. iOS static packaging and device - audio remain later phases. **Next:** Phase 4 managed server; prove persisted - libsodium Argon2id hash compatibility before account/database implementation. - -- **Done (2026-09-15): TLS exporter interoperability and persisted credentials.** Foundation commit - `b76181d` pushed to `origin/dotnet/foundations`. Added a nonblocking managed TLS 1.3 - session with certificate acceptance gate and directional media factories. Managed - loopback and C++ interoperability pass; exporter keys are captured inside BouncyCastle's - handshake callback before its exporter secrets are destroyed. The C++ TLS oracle - authenticates encrypted challenges in both directions against the existing mbedTLS - context. Added explicit persisted TOFU pins, compatible Ed25519 identity/PEM import, - new certificate identity SAN, and rejection of incomplete credential sets. - **Verified:** 42/42 managed tests with the native TLS oracle enabled; native dev build - and 29/29 CTest tests green; 16 permissive package licenses verified. Complete socket - orchestration and managed server/client state remain pending. - **Next:** Phase 3 codec/DSP wrappers and native packaging. - -- **Done (2026-09-15): Initial .NET wire/crypto port** on `dotnet/foundations`, from `cs-port`. - Added `dotnet/` solution, schema code generation, pipe framing, immutable voice headers, - directional media encryption/decryption, and xUnit conformance tests. Both platform - and managed crypto paths are tested. Added optional C++ fixture oracle, managed CI, - dependency lock files, license audit, and `docs/api-dotnet.md`. **Verified:** managed - Release build, 34/34 tests including C++ golden bytes, and 16 permissive package - licenses. Fresh `cmake --build --preset dev` and `ctest --preset dev` green (29/29); - regenerating the C++ fixtures produces identical bytes. Native codec/audio packaging - is deferred to its implementation phase. Next checkpoint: BouncyCastle TLS 1.3 - exporter interoperability with the existing server. - -- **Done (2026-07-23):** **First comment-density cleanup across core, server, and native - clients.** Condensed comments in the highest-noise audio, reconnect, registry, and binding - files; removed implementation history and narration; retained ABI ownership, threading, - real-time, ordering, and OS-API invariants. Moved the durable iOS audio-routing/pacing rules - to `docs/voice.md` and client reconnection policy to `docs/protocol.md`. No behavior, wire - format, or C ABI changes. **Verification:** `cmake --build --preset dev` green; - `ctest --preset dev` 29/29 green. `dotnet build VoiceCat.slnx` restores dependencies and - builds `VoiceCat.Interop` + `VoiceCat.App`, then fails in the unchanged test project because - `ExternalPcmTests.cs:49` references internal `NativeMethods` (`CS0122`). - -- **Done (2026-06-25):** **Fixed iOS AirPods-disconnect reinitialize loop on A2DP presets - (Stereo Mic / Mono Mic).** Regression from the 2026-06-25 audio-device-change recovery - commit below, which broadened the route-change recovery set from - `{oldDeviceUnavailable, newDeviceAvailable}` to "everything except - categoryChange/routeConfigurationChange". That added `.override` to the recovery set, and - `.override` is fired by our own `applyA2dpSpeakerFallback()` → - `overrideOutputAudioPort(.speaker)` — which `recoverAudio()` calls on every recovery. On an - A2DP preset with AirPods connected, disconnecting them ran: - `oldDeviceUnavailable` → `recoverAudio()` → `applyA2dpSpeakerFallback()` (no external - output now) → `overrideOutputAudioPort(.speaker)` → `.override` routeChange → - `recoverAudio()` → `applyConfiguration()` (setCategory resets the override) → - `applyA2dpSpeakerFallback()` → `overrideOutputAudioPort(.speaker)` → `.override` → … - Each iteration also called `IOSAudioEngine.reconfigure()` → `rebuild()` (a full - stop/restart of `AVAudioEngine`), which is the audible reinitialize loop + CPU spin the - user reported. Voice Chat (`.btHfpVoice`) and Built-in Mic + Speaker were unaffected - because `applyA2dpSpeakerFallback` early-returns for non-A2DP modes (no - `overrideOutputAudioPort` call, no `.override` notification). - - Two-part fix (no C ABI / proto / docs changes — pure Swift iOS-app target): - 1. **`AudioSessionManager.handleRouteChange`** (`AudioSessionManager.swift:182`): added - `.override` to the skip list alongside `.categoryChange`/`.routeConfigurationChange`. - `.override` is only ever fired by our own `overrideOutputAudioPort` call, so treating - it as a recovery reason is the loop by definition. The - `AVAudioEngineConfigurationChange` observer in `IOSVoiceProcessingEngine` remains as - the backstop for the case where an override actually stops the engine. - 2. **`IOSAudioRouter.applyA2dpSpeakerFallback`** (`IOSAudioRouter.swift`): made idempotent - via a `lastAppliedOutputOverride` tracker. Skips the `overrideOutputAudioPort` call - when the desired override (`.none` for external output present, `.speaker` otherwise) - already matches the last successfully applied value — so even if some other path - re-enters, the redundant override (and its `.override` notification) isn't fired. The - tracker is reset to `nil` at the top of `applyConfiguration()` (setCategory can reset - the override) and on a failed call. Defense-in-depth on top of fix 1. - - **Build:** `xcodebuild -project clients/apple/iOS/VoiceCatiOS.xcodeproj -scheme VoiceCatiOS - -destination 'generic/platform=iOS' build` green (Xcode 26.5 / iOS 18.0). The standalone - `swift test` in `clients/apple/` fails with `no such module 'VoiceCatC'` — pre-existing - (confirmed by stashing the changes: fails identically without them); the `VoiceCatC` C ABI - XCFramework isn't on SwiftPM's resolver path in this workspace. Not caused by this change - (the edit is in the iOS app target, not the `VoiceCatCore` SwiftPM package). - **Next (manual, on-device):** connect on the Stereo Mic preset, join voice, disconnect - AirPods — expect ONE `oldDeviceUnavailable` → one `recoverAudio` → one `engine started` → - one `override` routeChange (skipped, no further `recoverAudio`) and steady audio through - the loudspeaker. Also sanity-check AirPods reconnect and wired headphone plug/unplug - recover exactly once. - -- **Done (2026-06-25):** **iOS robustness — auto-reconnect after a network change + audio - recovery when audio devices plug/unplug.** Two layers of bugs the iOS client had: - (a) a `VC_EVENT_DISCONNECTED` from the C core on a Wi-Fi→cellular flip / DNS outage / - server restart used to leave the session dead with no retry; (b) unplugging wired - headphones or AirPods left the engine stopped forever — mic stopped transmitting and - remote audio stayed silent (the server connection itself survived, but the audio graph - did not recover). - - The first attempt wired reconnect into `AppState.handleConnectEvent`, but that handler - never runs for a live-session disconnect: once `SessionState.init` overwrites - `client.onEvent` (`SessionState.swift:87`), the `.disconnected` event is delivered to - `SessionState.handleEvent`, which used to play a cue and do nothing else. So the live - session would sit as a zombie for ~30-60 s (the C core's TCP keepalive/reaper timeout) - and then play the "connection lost" sound with no reconnect armed — exactly what the - user saw. The fix below has two parts addressing both the missing reconnect AND the - long wait. - - 1. **Event-driven reconnect** (`AppState.swift`, `SessionState.swift`): added a - `weak var appState: AppState?` to `SessionState`, set by AppState on auth success. - `SessionState.handleEvent` `.disconnected` now plays the cue and calls - `appState?.onLiveSessionDisconnected()` — the SINGLE path by which AppState learns a - live session dropped (since its own `handleConnectEvent` is bypassed for live-session - events). `onLiveSessionDisconnected` calls a shared `teardownLiveSessionAndReconnect` - that snapshots the live session into `LastSession`, stops the audio engine, - deactivates the AVAudioSession, nil's `session` (which releases `VoiceCatClient` → - `vc_client_destroy` joins the io thread), resets the backoff counter, and arms - `scheduleReconnect`. - 2. **Path-driven proactive reconnect** (`AppState.swift`): an `NWPathMonitor` - (`Network.framework`) now runs the whole time we're CONNECTED (started on auth - success, not only when armed for reconnect) and stays armed across reconnects. Its - `pathUpdateHandler` (dispatched to @MainActor) does two things: - - While connected: a primary-interface change (Wi-Fi↔cellular) OR the path becoming - `.unsatisfied` triggers `proactiveReconnect()` — tearing the live session down - BEFORE the C core notices the dead TCP read. This is what collapses the 30-60 s - reaper wait into ~1 s + the first backoff tick. Same-interface refreshes (Wi-Fi - BSSID roams, signal-strength changes) are intentionally ignored (signature - comparison via `pathSignature`); those usually don't break the TCP connection. - - While mid-reconnect (no session): a path becoming `.satisfied` resets the backoff - counter and arms `scheduleReconnect` for a fast-fresh retry. - `userInitiatedDisconnect` distinguishes manual `disconnect()`/`cancelConnect()` (which - set it true → cancel all reconnect state) from a network drop (which leaves it false). - On a successful reconnect, `reconnectAttempt` resets and `lastSession` clears; the - path monitor keeps watching for the next change. On user-initiated disconnect, all - reconnect state (task + path monitor + `lastSession` + `connectedServer`) is cancelled. - 3. **Backoff + restore**: exponential backoff 1s → 2s → 4s → 8s → 16s → 30s cap, - indefinite. TOFU pins match on the second connect (`VC_TOFU_MATCHED`) so the identity - gate auto-confirms; on auth success `SessionState.requestRestore` issues a - `joinChannel` and re-arms voice + restores the local mute/deafen state on the - resulting `.joinResult`. - 4. **Audio recovery** (`AudioSessionManager.swift`, `IOSVoiceProcessingEngine.swift`): - replaced the route-change handler's narrow `.oldDeviceUnavailable`/ - `.newDeviceAvailable` guard with a single intent-gated `recoverAudio()` path that - re-activates the AVAudioSession, re-applies the route config, and rebuilds the - engine; it runs on every externally-initiated route change reason except - `.categoryChange`/`.routeConfigurationChange` (those we cause ourselves and would - loop). Interruption-end now always calls `recoverAudio()` instead of only when - `.shouldResume` is set (which left the session permanently dead after Siri). Added - an `AVAudioEngineConfigurationChange` observer on the engine in `IOSAudioEngine` - that catches the case where iOS stops the engine itself AFTER our route-change - handler already rebuilt it (the previous rebuilds raced the engine's own self-stop - and lost). And `IOSAudioEngine.rebuild()` now does a one-shot reactivation-retry on - `engine.start()` failure — iOS sometimes refuses to start until the AVAudioSession is - re-activated, which is the silent-death case. - **Build:** `scripts/build-ios-client.sh --no-configure` green (Xcode 26.5 / iOS 18.0 sim - SDK, Swift 5 mode). No C ABI / `voicecat.h` / `voicecat.proto` / C core changes; the - existing TOFU auto-confirm (`VC_TOFU_MATCHED`) and idempotent `vc_join_channel` make - reconnect+restore possible without new C ABI. macOS and Windows clients unchanged. - **Next (manual, on-device):** verify unplugging AirPods/wired headphones mid-call keeps - audio going through the loudspeaker; verify Wi-Fi→cellular flip mid-call now triggers a - FAST reconnect (within a couple seconds, not 30-60 s) and lands in the same channel with - voice re-armed; verify tapping Disconnect mid-reconnect-abort cancels cleanly. - -- **Done (2026-06-24):** **Three bug fixes — voice join/leave, channel edit defaults, channel-update stream restart.** - 1. **Join/Leave Voice now truly subscribes/unsubscribes from the voice plane.** Previously - "Join Voice" only started the local mic — receiving was always on (gated by channel - membership alone). Added a protocol-level voice subscription concept: new - `SubscribeVoiceRequest`/`UnsubscribeVoiceRequest`/`VoiceSubscriptionResult` proto messages - (`core/proto/voicecat.proto`), `User.voice_subscribed` field, `vc_join_voice`/`vc_leave_voice` - C ABI functions (`core/include/voicecat.h`), `VC_EVENT_VOICE_STATE` event, server-side - `voice_subscribed_` flag on `ConnSession` checked by the SFU relay's recipient filter - (`SessionRegistry::find_channel_sessions` excludes non-subscribers; `MediaRelay::on_udp_frame` - also skips non-subscribed senders). The core client gates `sync_remote_streams` on - `voice_subscribed_`, tears down all remote decoders + stops local streams on leave, and - re-syncs from the session model on join. All three clients (Windows/macOS/iOS) rewired - their Join/Leave Voice button to call `joinVoice`+start mic / `leaveVoice`+core stops mic. - The configured input mode (PTT/VAD/AlwaysOn) takes effect on join — no extra mic button. - Text chat works regardless of voice subscription. **Apple clients not yet compile-verified - (Windows environment).** - 2. **Channel edit dialog now shows the channel's actual current settings.** The read struct - `vc_channel` (`voicecat.h`) was missing `sort_order` and `audio` fields — only the write - struct `vc_channel_info` had them. Extended `vc_channel` with both (additive, no ABI break), - updated the session model (`session::Channel`) and `apply_snapshot`/`apply_channel_event` - to populate them, and updated `vc_list_channels` marshaling. All three clients now build - the edit descriptor from the actual channel info instead of hardcoded defaults. - 3. **Channel parameter updates now automatically restart everyone's streams.** Previously - editing a channel's audio config (codec/bitrate/sample-rate/FEC/DTX/etc.) persisted and - broadcast a `ChannelEvent::UPDATED`, but no layer restarted streams — encoders/decoders - are frozen at announce time. `handle_channel_event` (`core/src/core/client.cpp`) now - detects audio-config changes on the user's current channel and calls - `restart_active_streams_for_channel`, which stop→starts each active local stream. The - server reads the updated channel config on re-announce, and peers' `sync_remote_streams` - wire up fresh decoders at the new ssrc. The `LocalStream` struct now retains the stream - label across restarts. No server or protocol change needed. - -- **[ ] Soon — jitter buffer should measure REAL arrival jitter (RFC 3550), not sender - timestamps.** `JitterBuffer::push` (`core/src/audio/audio_engine.cpp:84-108`) estimates - jitter from `gap = ts - last_push_ts_`, where `ts` is the **sender's timestamp** — which is - perfectly regular (`ls.timestamp += samples` every frame, independent of when the packet is - actually sent). So `diff` is always ~0, `jitter_est_` stays 0, and `target_depth_ms_` is - pinned at its ~20 ms floor. The buffer is therefore **blind to real network/arrival jitter - and to bursty senders** — it never deepens. Combined with the playout deliberately seeding - to near-zero depth (`on_playback`, ~line 715), the receiver tolerates only a *steady* - sender. This is exactly why the iOS mic needed a send-side pacing cushion (below) and why - genuine network jitter would also cause underruns. **Fix:** measure inter-arrival jitter - the RFC 3550 way — `D = (arrival_j - arrival_i) - (ts_j - ts_i)` using a wall-clock arrival - stamp captured in `push()` — and drive `target_depth_ms_` off that EWMA (keep the existing - marker/silence-gap outlier rejection). Then the receiver absorbs bursts itself and the iOS - send cushion could be reduced or removed. Shared-core change → add a test and re-verify - desktop↔desktop stays low-latency (steady sender ⇒ ~0 arrival jitter ⇒ no regression). - -- **Done (2026-06-24):** **Windows PTT can now work system-wide (in the background).** Previously - the PTT key was focus-scoped (WinForms `KeyDown`/`KeyUp`, dead the moment the window lost - focus). Added an AV-safe global path using the **Raw Input API** (`RegisterRawInputDevices` + - `WM_INPUT` with `RIDEV_INPUTSINK`) — *not* a `WH_KEYBOARD_LL` low-level hook, which is the - keylogger pattern AV heuristics flag (worse for our unsigned MinGW binary). New - `clients/windows/VoiceCat.App/Native/RawInput.cs` (P/Invoke + structs); `MainForm` overrides - `OnHandleCreated`/`OnHandleDestroyed`/`WndProc` to register the keyboard sink and handle - `WM_INPUT`, gates the focus-scoped `KeyDown`/`KeyUp` handlers off when system-wide is on, makes - the `Deactivate` force-release conditional, and adds a `GetAsyncKeyState` watchdog on the pump - timer so a missed key-up (RDP/lock-screen focus switch) can't leave PTT stuck. New - `VoiceSettings.SystemWidePtt` (default ON) with a "Works in the background (system-wide)" - checkbox in the Audio settings PTT section. Build green (`dotnet build`, 0 warnings). **Next - (manual):** verify background PTT against a live server, and confirm the binary trips no AV - keyboard-hook detection. - -- **Done (2026-06-23):** **Fixed: receive-side noise reduction silently skipped on stereo mic - streams (regression from stereo-mic capture below).** The per-listener NR toggle - (`vc_set_remote_stream(... noise_reduction)`) did nothing on Windows/macOS/iOS — the UI and - the whole C-ABI→core path were correctly wired, but the decode loop gated the RNNoise pass on - `dec_channels == 1` (`core/src/audio/audio_engine.cpp`), an old proxy for "this stream is - voice" that assumed *stereo ⇒ screen-share*. The stereo-mic commit broke it: a stereo mic with - **send-side NR off** transmits stereo Opus, so the receiver decoded `dec_channels == 2` and - skipped NR entirely (gain/mute have no channel guard, which is why only NR looked broken). - **Fix:** thread the stream *kind* through `init_recv_stream` into `RemoteStream::is_voice` - (set from `si.kind() == STREAM_MIC` in `client.cpp`), gate receive NR on `is_voice` instead of - channel count, and fold a stereo voice frame to mono → denoise → duplicate back across both - channels in place (symmetric with the send-side downmix; RNNoise is mono-only). A stereo voice - stream now plays mono while NR is on; a screen-audio share is never touched. New test - `tests/test_recv_noise_reduction.cpp` drives `AudioEngine` and asserts a stereo voice stream's - noise floor collapses with NR on (RMS 1046 → 0.1) while a screen-audio share stays unchanged - (RMS ≈ 1015). Full `ctest --preset dev` green — **29/29**. Docs: voice.md §10. Clients need no - change (shared-core fix). Not yet re-verified two-client E2E on real hardware. - -- **Done (2026-06-23):** **Stereo mic capture on Windows & macOS desktop clients.** Both - desktop mics were hard-mono: `ensure_audio_running()` defaults `capture_channels = 1` and - neither client ever called `vc_set_capture_channels` (only iOS did). Added a **"Stereo - microphone" toggle** to each client's Audio settings (off by default, persisted — - `VoiceSettings.StereoMic` on Windows, `MainWindowController.stereoMic` / - `voice.stereoMic` UserDefaults on macOS). It's applied to the core when the mic stream - starts (stored on the stream before the announce round-trip, so the first device open picks - it up) and live in settings via `vc_set_capture_channels` + `vc_audio_restart`. Exposed both - ABI calls in the Windows interop (`NativeMethods`/`VoiceCatClient`); the macOS wrapper already - had them. **Core fix:** `encode_and_send_frame` (`core/src/core/client.cpp`) now folds a - stereo mic frame to mono when the channel is mono — previously the `channels == 2` branch - encoded interleaved L/R directly even on a mono channel, feeding a mono `opus_encode` 2× its - samples (wrong pitch / garbage). Real stereo still only reaches the wire on a **stereo - channel** (encoder channel count = channel's Opus mode); on a mono channel the mic is cleanly - downmixed. Test: `test_stereo_mic_mono_channel` in `tests/test_vad_ptt_devices.cpp`. Full - `ctest --preset dev` green — 28/28. macOS Xcode build not compiled here (Windows host); the - Swift changes follow existing `nrChanged`/`setInputDevice` patterns. Docs: voice.md §8. - -- **Done (2026-06-23):** **Fixed iOS dual-stream / crackly mic — core opened a second - (miniaudio) capture device alongside the AVAudioEngine tap.** Symptom: with two clients in - a channel, the remote end heard the iOS mic **twice** and crackly. With Voice Chat + a BT - headset, both the BT mic and the internal mic were captured; with Stereo Mic, both a mono - and a stereo copy of the internal mic were sent simultaneously. Root cause is a timing gap - in `vc_client::ensure_audio_running()` (`core/src/core/client.cpp`): `external_capture` was - only set when a MIC stream already existed, but `ensure_audio_running` is also called from - `sync_remote_streams` (triggered by the post-auth `ServerStateSnapshot`) **before** the user - joins voice — so with no MIC stream, `external_capture` stayed `false` and - `AudioEngine::start()` opened a real miniaudio capture device. Later the user joined voice → - `IOSAudioEngine.startMic` installed the AVAudioEngine input tap → `feedPcm` → - `inject_capture` → `on_capture_frame`. The miniaudio device was still open (the engine was - already `running()`, so the later `ensure_audio_running` early-returned and never applied - `external_feed`), and `on_capture_frame` encodes+sends every frame with **no deduplication** - → the mic was sent twice. The two unsynchronized capture clocks interleaving in the encoder - is the crackle; the mono miniaudio device + stereo AVAudioEngine tap is the "mono and stereo - at the same time" on Stereo Mic. - - **Fix 1 (core, `core/src/core/client.cpp:ensure_audio_running`):** force - `p.external_capture = true` whenever `external_playback_` is set. In iOS unified mode the - core must never open a hardware capture device — the AVAudioEngine owns the only mic path. - No-op on desktop (`external_playback_` is never set there). - - **Fix 2 (iOS, `clients/apple/iOS/VoiceCatiOS/AppState.swift`):** move - `client.setExternalPlayback(true)` from the `authResult` handler to **before** - `client.connect(...)`. The server sends `AuthResult` immediately followed by - `ServerStateSnapshot`; `handle_server_state` runs `ensure_audio_running` on the io thread - before the main thread drains `authResult`, so setting the flag post-auth raced. Setting it - pre-connect guarantees `external_playback_` is true before any message is processed — - eliminating the playback-device race too (the mixer timer + AVAudioEngine playback path + - VPIO AEC reference are correct from the first frame). - - **Verify:** `cmake --build --preset dev` clean; `ctest --preset dev` = 24/28 — the 4 - failures (`vad_ptt_devices`, `external_pcm`, `frame_ms_reframe`, `channel_samplerate`) are - a **pre-existing** teardown `mutex lock failed` race, reproduced identically with the - changes stashed. `external_playback` (the one test exercising this code path) **passes**. - No xcframework rebuild needed (no new symbols). **Next (manual, on device):** two clients - in a channel — Voice Chat + BT, and Stereo Mic — confirm the remote end hears the iOS mic - once, clean (no duplicate, no crackle); confirm the iOS user hears the remote user cleanly - with AEC working in Voice Chat. - -- **Done (2026-06-23):** **Fixed iOS mic flutter / crackle / octave-up.** The iOS mic was - unusable: a consistent ~40–60 ms flutter with volume fade ("talking through a slow fan") on - every preset. Root cause: the core sends each captured frame **synchronously** - (`on_capture_frame` → `encode_and_send_frame`, no send pacer), so packet cadence == capture - cadence; and the receiver's playout keeps **near-zero buffering** by design and its jitter - estimate is blind to arrival timing (see RFC-3550 item above). That's smooth only for a - *steady* sender (desktop miniaudio = steady 20 ms), but the iOS `AVAudioEngine` input tap - delivers ~2 frames per ~40 ms callback (more under VPIO) → bursty → receiver underruns → PLC - fade. - - **Fix (iOS-only, `clients/apple/iOS/VoiceCatiOS/IOSVoiceProcessingEngine.swift`):** the - mic tap converts to 48 kHz int16 and writes a lock-free SPSC ring; a 20 ms feed pump - drains it and calls `feedPcm` at a **steady** cadence so packets leave the core every - 20 ms (what the receiver expects). The pump **primes a small prebuffer cushion** - (`PumpState.targetFrames`, 3 frames ≈ 60 ms, self-healing up to ~120 ms on underrun) - before releasing, so the tap's bursts can't drain it to empty. Two correctness rules - (each had bit us): never read a partial frame (`read` consumes what it returns → - discarding partials caused crackle), and rebuild the pump with the current channel count - every `rebuild()` (a frozen channel count fed mono-as-stereo = octave-up on a Stereo→Voice - Chat switch). Trade-off: ~60–120 ms added mic-send latency — unavoidable when de-bursting - for a near-zero-buffer receiver; the RFC-3550 fix above would let us shrink it. - - **Verify:** `xcodebuild` Debug **BUILD SUCCEEDED** (iOS Simulator, arm64). Audible test - requires a real device (simulator has no real mic route): mic should be smooth on Voice - Chat / Mono Mic / Stereo Mic, including switching presets while live (no octave). - -- **Done (2026-06-23):** **Fixed Apple client link failure (stale xcframework missing - RNNoise).** Both `VoiceCatMac` and `VoiceCatiOS` failed to link with `Undefined symbols for - architecture arm64: _rnnoise_create / _rnnoise_destroy / _rnnoise_process_frame`. Root - cause: `clients/apple/scripts/build-xcframework.sh` merged vcpkg deps into the fat static - lib but NOT the locally-built vendored `librnnoise.a` (a CMake target from - `third_party/rnnoise/`, linked privately into `voicecat` via `VOICECAT_HAS_NS` — not a - vcpkg dep). The xcframework had been rebuilt at 14:17 after the RNNoise commit but still - omitted the symbols, so every slice's `libvoicecat-fat.a` referenced `_rnnoise_*` with no - defining object. The iOS slices were also stale (pre-rnnoise) and absent from the - xcframework entirely. - - **Fix:** `build-xcframework.sh` now collects `.a` files from `build//lib/` - (excluding `libvoicecat*`) in addition to `vcpkg_installed//lib/`, so vendored - CMake-target static libs like `librnnoise.a` are merged into the fat lib. Future-proof: - any new vendored static-lib target landing in `build//lib/` is picked up - automatically. README "Fat static library" section updated. - - **Verify:** rebuilt `VoiceCatCore.xcframework --all` → all 3 slices (macos-arm64, - ios-arm64, ios-arm64-simulator) now carry 10 `_rnnoise_*` symbols each; fat lib - ~30 MB → ~33 MB. `xcodebuild` Debug **BUILD SUCCEEDED** for `VoiceCatMac`, - `VoiceCatiOS` (iphonesimulator arm64), and `VoiceCatiOS` (iphoneos arm64, - `CODE_SIGNING_ALLOWED=NO`). No core/ABI/proto changes — xcframework artifact only. - -- **Done (2026-06-23):** **Remote-stream noise suppression — real backend (RNNoise).** The - two-sided NR plumbing (`RemoteStream::recv_ns` + `vc_set_remote_stream(... noise_reduction)`) - was wired but **inert** — `ApmProcessor::create()` returned a no-op passthrough, because the - originally-planned `webrtc-audio-processing` won't build on Windows/macOS. Replaced with - **RNNoise** (BSD-3 + CC0), vendored at `third_party/rnnoise/` (the vcpkg port is `!windows - !arm`), built as a standalone C static lib + `VOICECAT_HAS_NS`. One `RnnoiseProcessor` - (`core/src/audio/apm_processor.cpp`) now backs **both** NR paths: - - **Receive-side** (per-listener, per-`ssrc`): lit up automatically via the factory; gated to - mono streams (`audio_engine.cpp` ~L791). - - **Send-side** (mic, new): `vc_set_input_noise_reduction(client, enable)` ABI + - `vc_client::mic_ns_`, run before input gain/VAD in `on_capture_frame`. A stereo mic is - downmixed to mono **only when NR is on**; with NR off a stereo mic keeps full stereo. - - RNNoise is mono/48 kHz/480-sample; our clock is fixed 48 kHz and Opus frame sizes are all - multiples of 480, so no resampling. RT-safe: alloc at construction, lock-free in the callback. - - **Verify status:** `ctest --preset dev` green — **28/28** (new `noise_suppression` test: - feeds white noise through `ApmProcessor::create()`, measures **99.9%** RMS reduction). Build - clean on the `dev` MinGW preset. **Next (manual):** add the on/off toggles to the client UIs - (Windows Audio Settings dialog, macOS/iOS settings) calling the two ABIs; build `windows-client` - + `apple-dev` presets to confirm RNNoise compiles under MinGW-DLL and arm64; two-client E2E. - -- **Done (2026-06-23):** **Aux outgoing stream (mic + a second input device) — Windows + macOS.** - Users can now transmit a second hardware input device (e.g. line-in / aux) alongside the mic, with - its own device picker and volume, from Audio Settings. **No core/ABI/proto changes** — the aux is - a `VC_STREAM_AUX_DEVICE` stream started with `external_feed=1`, captured client-side, and fed via - `vc_stream_feed_pcm` (the same external-feed pipeline screen-audio uses). Per-kind `local_streams_` - already allows mic + screen + one aux to coexist; volume is a client-side gain multiply (the core's - `vc_set_input_gain` is mic-only/global). The aux is always-on (the core never gates `AUX_DEVICE` on - VAD/PTT) and is tied to the voice session (started on Join Voice when enabled, stopped on Leave). - - **Windows:** new `Audio/InputDeviceCapture.cs` (WASAPI shared-mode capture from a real input - endpoint via `IMMDevice.Activate(IAudioClient)`, 48 kHz/s16, 20 ms frames) + `InputDeviceEnumerator` - (WASAPI capture-endpoint list — separate from the core's miniaudio ids). Aux section in - `AudioSettingsForm.cs` (enable checkbox, device combo, refresh, volume slider, accessible names, - live-apply + Cancel revert via callbacks). Lifecycle in `MainForm.cs` (`_auxStreamId` + - `InputDeviceCapture`). Persisted in `VoiceSettings.cs` (`AuxEnabled/AuxDeviceId/AuxGain`). - - **macOS:** new `Audio/InputDeviceCapture.swift` (AVAudioEngine input-node tap pinned to the chosen - Core Audio device via `kAudioOutputUnitProperty_CurrentDevice`; AVAudioConverter → 48 kHz int16; - 20 ms framing modelled on `ScreenAudioCapture`) + `InputDeviceEnumerator` (Core Audio device list - by stable UID). Aux section in `SettingsWindowController.swift`; lifecycle + UserDefaults - persistence (`voice.aux*`) in `MainWindowController.swift`. New file added to `project.pbxproj`. - - **Verify status:** Windows C# solution builds clean (0 warn/0 err); `ctest` core suite unchanged - (no core edits). **Next (manual):** on a Mac, build `VoiceCatMac.xcodeproj`; then two-client E2E — - enable aux on a second input device, confirm two distinct streams for the sender and that the aux - volume slider moves the aux level independently of the mic; confirm persistence across relaunch. - -- **Done (2026-06-23):** **Input-settings persistence, mic input gain, + two iOS bugs (all 3 - clients).** Four fixes: - 1. **Input settings now persist.** Transmission mode (VAD/PTT/Always-On), VAD threshold, and the - new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD - defaults. Each client now persists them and re-applies on connect: iOS via `UserDefaults` - (`SessionState.loadAndApplyVoiceSettings` + setter writes, keys `voice.*`); macOS via - `UserDefaults` (`MainWindowController` `didSet` + `loadPersistedAudioSettings`, also restores - the VAD slider from the stored threshold); Windows via new - `VoiceCat.App/Models/VoiceSettings.cs` (JSON at `%AppData%\VoiceCat\voice.json`, mirrors - `FeedbackSettings`) loaded/applied in `MainForm`. - 2. **Microphone input gain.** New global send-side API `vc_set_input_gain` (voicecat.h → - `client.cpp::on_capture_frame`, applied to MIC PCM before the VAD gate, clamped to int16) plus - Swift (`setInputGain`) and C# (`SetInputGain`) bindings. Mic-volume slider (0–300 %, default - 100 %) added to all three clients' input settings, persisted with the rest. - 3. **iOS chat send fixed.** `ChatView` called `sendText(scope:.channel)` with no `targetId` (→ 0), - so channel messages went nowhere; now passes `session.currentChannelId`. - 4. **iOS per-user tuning reachable via VoiceOver.** The tuning sheet was long-press - `.contextMenu` only (invisible to VoiceOver); `UserRow` now also exposes the same buttons as - `.accessibilityActions` (no visual change), so the actions rotor reaches tuning + admin actions. - - **Verified:** core `cmake --build --preset dev` clean; `ctest --preset dev` = 24/27 (the 3 - failures — `external_pcm`, `frame_ms_reframe`, `channel_samplerate` — are a pre-existing - teardown crash on this machine, reproduced identically with the changes stashed). xcframework - rebuilt (`--all`); **VoiceCatMac** and **VoiceCatiOS** (arm64 sim) → BUILD SUCCEEDED; - `VoiceCat.Interop` (`dotnet build`) succeeded. **Windows App not built** (WinForms - net10.0-windows can't build on macOS) — changes follow existing patterns; needs a Windows - build + manual check. - - **Next (manual):** on each client, set PTT + non-default VAD/mic-gain, relaunch → settings - restored; boost a quiet mic and confirm others hear it louder; iOS send a channel message; - iOS VoiceOver → focus a user → actions rotor opens tuning. - -- **Done (2026-06-22):** **Fixed growing voice latency (jitter-buffer depth ratchet).** Symptom: - end-to-end latency grew to multiple seconds and "drifted backward," reset only by leaving/ - rejoining voice (DTX/FEC/DRED on, 10% loss). Root cause was **not** the codec settings (10% loss - is just an `OPUS_SET_PACKET_LOSS_PERC` encoder hint; FEC/DRED add no standing latency) but the - receiver playout logic in `core/src/audio/audio_engine.cpp`: the playout clock free-ran in real - time while the sender omitted silence from its timestamps and set **no header flags at all**, and - the only correction snapped the clock to the *oldest* buffered frame (could only *add* latency) — - with `target_depth_ms_` computed but never enforced, so latency could only grow or be reset. - **Fix:** bounded-depth playout — (re)seed to the *leading edge* (newest frame) on start/marker/ - starve, and **frame-skip catch-up** that trims a backlog beyond `target + hysteresis` (the missing - downward force). Plus hardening: adaptive late-drop window, talkspurt `kFlagMarker`/`kFlagDtx` - now actually stamped by the sender (`client.cpp` send path) and consumed on recv, EWMA outlier - rejection (silence gaps/stragglers no longer poison the estimate), duplicate counting, ring- - underrun diagnostics (`stream_underruns`/`stream_duplicates`). New regression test - `tests/test_jitter_depth.cpp` asserts depth stays bounded (<200 ms) while arrivals outrun playout - for ~4 s. `ctest --preset dev` green — **27/27**. Docs: `docs/voice.md` §5 rewritten. - - **Next (manual E2E):** two clients in a channel, DTX/FEC/DRED on — talk in alternating bursts - for several minutes and confirm latency stays low/stable (no backward drift, no rejoin needed). - -- **Windows done / Apple awaiting Mac build (2026-06-22):** **Event sound effects + optional - text-to-speech for all clients.** Clients now play a cue per session event and can optionally - speak it (TTS off by default; when on it announces joins/leaves and reads message/PM bodies). - One canonical event→sound mapping (defined off the shared C ABI `vc_event` stream) is mirrored - across all three clients; `self` vs others is `user_id == self_user_id`, and outgoing messages - echo back as events so sent/recv cues need no separate send-path hook. Conservative defaults - (join/leave, channel/PM sent+recv, login, logout, connection-lost, mic on/off ON; per-utterance - self voice-activity `va_start/va_stop` and the PTT cue OFF). WAVs ship from `assets/sounds/`. - - **Windows (built + verified):** new `VoiceCat.App/Notifications/` (`FeedbackSettings` → - `%AppData%\VoiceCat\feedback.json`, `SoundPlayerPool` via `System.Media.SoundPlayer`, - `SpeechAnnouncer` via the **Prismatoid** NuGet 0.3.0, `EventFeedback` dispatcher); hooks in - `Forms/MainForm.cs`; `Forms/NotificationSettingsForm.cs` under a new **Settings ▸ Notifications** - menu. `.csproj` adds the Prismatoid PackageRef and copies the WAVs into `sounds\`. `dotnet build` - clean; WAVs + `Prismatoid.dll` confirmed in output. Note: `SoundPlayer` has no gain control, so - volume is honoured as a mute gate (0 = silent) — swap to NAudio if finer/overlap control is needed. - - **macOS + iOS (written, NOT yet built — needs a Mac):** shared `Sources/VoiceCatCore/Feedback/` - (`SoundEvent`, `EventFeedback` = `AVAudioPlayer` pool + native `AVSpeechSynthesizer`, - `FeedbackSettings` over `UserDefaults`); WAVs copied into `Sources/VoiceCatCore/Sounds/` and - bundled via `Package.swift` `resources: [.process("Sounds")]` (`Bundle.module`). Hooks: iOS - `SessionState.handleEvent` (+ split `userJoined`/`userLeft`, added a `.disconnected` cue case), - `AppState` auth-success login cue, PTT cue in `setPushToTalk`; macOS `MainWindowController` - handlers + NSEvent PTT monitor. Settings UI: iOS `SettingsView` Notifications section - (`@AppStorage`), macOS `SettingsWindowController` checkboxes + volume slider. No `.pbxproj` - edits needed (shared files are SPM-managed; app files already in the projects). - - **Next:** on a Mac, `clients/apple/scripts/build-xcframework.sh --all` then build - VoiceCatMac/VoiceCatiOS; fix compile fallout. **Watch the iOS audio session:** cues/TTS play over - the live VPIO `playAndRecord` session — verify they mix and don't duck/interrupt the call or get - silenced by the mute switch (most likely bug site). Then run `ctest --preset dev` (unchanged — - no core/server code touched). - -- **Done (2026-06-22):** **UDP media now shares the TCP port (self-host port-forward fix).** Symptom: a - remote self-hosted server (`iamtalon.me:8384`, TCP+UDP 8384 forwarded) accepted TCP connections but - passed no voice. Root cause: `Config::media_port` defaulted to `0` = OS-assigned, and `main.cpp`'s - `--port` only set `bind_port` (TCP) — so the UDP relay bound a *random high port*, advertised it to - clients in HELLO (`udp_port`), and clients sent voice there. With only `8384/udp` forwarded those - packets were dropped → connect OK, no audio. This contradicted `docs/deployment.md` ("Control and media - share one port number on TCP+UDP"). **Fix (`server/src/server.cpp`):** media follows bind_port when - `media_port == 0` — `media_want = cfg_.media_port != 0 ? cfg_.media_port : cfg_.bind_port`. The - `0 = OS-assigned` escape hatch survives when `bind_port` is also 0, so tests that bind ephemeral ports - are unaffected (kept the logic in server.cpp rather than hardcoding 8384 as the default, which would - collide parallel tests on UDP 8384). Banner now reads `TCP :8384 UDP :8384`. Build + `ctest --preset - dev` green (24/24); live-verified banner with `--port 8390` → `UDP :8390`. **Action for self-hosters:** - redeploy and confirm the startup banner shows matching TCP/UDP ports; the existing single forward rule - is now correct. If voice still fails, watch the server's rate-limited `[media] dropped frames — - unmapped-endpoint=…` line (NAT source-port rewrite would be the next suspect). - -- **Done (2026-06-23, Swift-only — no core/ABI change; awaiting on-device verification):** **iOS audio - stack unified — one always-external `AVAudioEngine`, miniaudio dropped on iOS.** The iOS audio path was - a fragile hybrid: Voice-Chat-class presets ran a native VPIO `AVAudioEngine` (core external) while - Stereo/Studio/A2DP presets ran the core's miniaudio devices. Nearly every bug lived in the seam - (lingering miniaudio capture unit fighting VPIO, the `audioRestart` ordering dance, the route-change - "glitching" loop, stereo↔mono stickiness, "can't hear anyone"), and switching presets/routes mid-call - routinely dropped input, output, or both. **Fix: drive *all* iOS audio through one `AVAudioEngine` with - the core fully external at all times** — `vc_set_external_playback(1)` once at connect, every MIC stream - `external_feed=1`, mic via `vc_stream_feed_pcm`, playback via `vc_set_mixed_output_sink`. - - `IOSVoiceProcessingEngine.swift` → **`IOSAudioEngine`** (same file): always-on `AVAudioSourceNode` - playback (runs whenever connected, so remote audio plays before you join voice); conditional mic tap; - VPIO + AGC toggled per config. One private `rebuild()` (stop → set VPIO → install tap → start) backs - `startListening`/`stop`/`startMic`/`stopMic`/`reconfigure`/`setCaptureChannels`. Kept the `PCMRing` - and ring-stats diagnostics. - - `IOSAudioRouter`: presets cut from seven to **four** — Voice Chat (VPIO mono, system output), - Stereo Mic / Mono Mic (internal built-in mic regardless of output, A2DP-capable, no VPIO), Advanced - (manual). New persisted `voiceProcessingEnabled` (master AEC+NS) + `agcEnabled`; setters now call - `IOSAudioEngine.reconfigure()` instead of `client.audioRestart()` + `reconcileVoicePath`. Kept the - proven AVAudioSession recipes (category/mode/options, stereo capsule, `applyA2dpSpeakerFallback`). - - `AudioSessionManager` slimmed (drops `client`/`activeMicStreamId`/`reconcileVoicePath`; adds - `isActive`); interruption-end & device-change now `reconfigure()` the engine. `SessionState` - `doStartMicStream`/`stopMicStream` collapsed to start-stream + `startMic`/`stopMic` (no - `setExternalPlayback`/`audioRestart` toggling); `reconcileVoicePath` deleted. `AppState` sets external - playback + `startListening` at connect, `stop()` at disconnect. `SettingsView` → four presets + - Advanced VPIO/AGC toggles. - - **No core/ABI/test change** — relies on the already-shipped `vc_set_external_playback` / - `external_feed` / `vc_set_mixed_output_sink` / `vc_stream_feed_pcm` path (`test_external_pcm`, - `test_external_playback`). `xcodebuild` iOS device Debug **BUILD SUCCEEDED**. **Rebuild the - xcframework is NOT required** (no new symbols). - - **Next (user, on device):** two iPhones in a channel — verify BOTH directions survive every - transition and are never silent unless intended: Voice Chat (no echo, NR), listen-only before joining, - join↔leave repeatedly, switch Voice Chat↔Stereo↔Mono↔Advanced *while in voice*, A2DP connect/unplug, - wired connect/unplug, phone-call interruption + resume, screen-audio share. - -- **Superseded by the 2026-06-23 unification above (2026-06-22):** **iOS real echo cancellation / noise - suppression via native VPIO.** Root cause of "voice chat doesn't sound like a call" (echo + no NR): real iOS - AEC/NS/AGC come only from Apple's Voice-Processing I/O unit (VPIO), but the core uses miniaudio's - plain RemoteIO units — so `.voiceChat` mode alone never engaged AEC. Fix moves both mic capture and - playback to a native Swift `AVAudioEngine` (`setVoiceProcessingEnabled`) on the AEC presets, with the - core in external mode. - - **Core (done, builds + tests green):** new ABI `vc_set_mixed_output_sink` + `vc_set_external_playback` - (voicecat.h PATCH→2). `AudioEngine` gains a mixer-timer thread that drives `on_playback` (decode+mix) - on a ~20 ms cadence with NO hardware playback device and ships the final mix to the mixed-output - sink; `start()` also skips the hardware capture device when the MIC stream is `external_feed` - (`AudioParams.external_capture`). New white-box test `test_external_playback` (23/24; - pre-existing `external_pcm` teardown crash on Darwin 25.5 is UNRELATED — original tree crashes too). - - **Swift (done, builds):** `VoiceCatCore` wrappers (`externalFeed` on `StreamDescriptor`, - `setMixedOutputSink`, `setExternalPlayback`); new `IOSVoiceProcessingEngine.swift` (VPIO - `AVAudioEngine`: mic tap→`feedPcm`, mixed-sink lock-free ring→`AVAudioSourceNode`); - `IOSAudioRouter.currentConfigUsesVoiceProcessing` gates the path per preset; `SessionState` - join/leave + `reconcileVoicePath()` switch between VPIO and the miniaudio path; Voice Chat defaults - to speaker; SettingsView shows AEC/NS state. **Rebuild the xcframework** before building the app: - `clients/apple/scripts/build-xcframework.sh --all` (new ABI symbols). `xcodebuild` iOS sim Debug - BUILD SUCCEEDED. - - **Post-verification fixes (2026-06-22, Swift-only — no core/ABI change):** two on-device bugs fixed. - - **Voice Chat (VPIO) silent playback:** `SessionState.doStartMicStream()` called `audioRestart()` - BEFORE `startStream`, so when the engine was already running (a remote stream had started it) it - reopened with `external_capture=false` and opened a hardware miniaudio capture device; the - announce-result restart then early-returned (engine already running) so that device was never - dropped and fought the `AVAudioEngine` VPIO unit, silencing playback. Fix: set - `setExternalPlayback` first, then `startStream` (which stores `external_feed` synchronously), THEN - `audioRestart()` — the core reopens in full external mode (no hardware devices). Added VPIO - diagnostics (graph/route formats at start; ring written/read totals at teardown). - - **Stereo Mic / Studio quiet earpiece:** the `.builtInMicBtA2dp` presets omit `.defaultToSpeaker` - (it breaks A2DP) and skip `forceSpeaker`, so with no Bluetooth connected output pinned to the quiet - receiver. New `IOSAudioRouter.applyA2dpSpeakerFallback()` overrides to the built-in speaker when no - external (A2DP/wired/AirPlay) output is present, clears the override when one is — called after - activation and on device-change route changes (`AudioSessionManager`). - - **Next (user, on device):** two iPhones on speaker, Voice Chat preset → confirm (a) no echo, (b) - background noise suppressed, (c) speaker output by default AND remote audio is now audible; then - Stereo Mic / Studio with no BT → confirm loud speaker (not earpiece), and A2DP takes over when a BT - headset connects. Tune the mixer-timer/ring sizing if there's under/overrun. - -- **Done (2026-06-21):** **Docker + Linux deployment + GitHub Actions cross-build.** Added the complete Linux server - deployment story (the only missing platform — Windows and macOS already have native - binaries): - - `Dockerfile` — multi-stage (builder: `ubuntu:24.04` + vcpkg + `cmake --preset - server-release`; runtime: `ubuntu:24.04`, non-root `voicecat` user, `/data` volume, - TCP+UDP 8384). vcpkg is fetched via the GitHub archive tarball at the exact - `builtin-baseline` commit (`d46283cf…`), avoiding a full git-history clone. BuildKit - cache mounts on `/vcpkg/downloads`, `/vcpkg/buildtrees`, `/vcpkg/packages` (scoped by - `TARGETARCH`) keep rebuilds fast. Both `voicecat-server` and `voicecat-admin` are - copied into the runtime image. - - `docker-compose.yml` — single-service compose file with `restart: unless-stopped`, - named volume `voicecat-data`, and port mappings for TCP+UDP 8384. `command:` shows - how to set `--name`. - - `.dockerignore` — excludes `.git/`, `build/`, `clients/` (Swift/C# code), `docs/`, - markdown, editor config; build context is just `core/`, `server/`, `tools/`, `cmake/`, - and the three root CMake/vcpkg files. - - `deploy/linux/voicecat.service` — hardened systemd unit (non-root, `ProtectSystem`, - `NoNewPrivileges`, `AmbientCapabilities=CAP_NET_BIND_SERVICE`) for bare-metal deploys. - - Multi-arch: `docker buildx build --platform linux/amd64,linux/arm64 .` works without - any triplet override — `cmake/voicecat-toolchain.cmake` auto-detects from the host - arch cmake sees inside the buildx container. - - Quick start: `docker compose up -d` (or `docker run -d -p 8384:8384/tcp -p - 8384:8384/udp -v voicecat-data:/data voicecat`). First run auto-generates identity - + cert + DB; check logs for fingerprint + admin password. - - **GitHub Actions** (`.github/workflows/build-linux.yml`): primary cross-platform - binary build path — amd64 uses `ubuntu-24.04`, arm64 uses `ubuntu-24.04-arm` - (native, not QEMU). Triggers on push to main (when C++/cmake files change) and - manually via `workflow_dispatch`. Downloads land as 90-day artifacts. - `scripts/build-linux-binaries.sh` is the local Docker fallback (needs ~10–15 GB - free disk; suits Linux dev machines, not Windows Docker Desktop). - -- **Done (2026-06-21):** **Fix permanent voice-loss bug + harden the UDP media path (protocol v2).** - Field report: two iOS users lost all audio mid-call after a bad-network blip and could not - recover even by restarting the apps. Root causes found in the UDP media path: - 1. **Anti-replay window poisoned by unauthenticated packets (the trigger).** - `SodiumMediaCrypto::open()` advanced `recv_highest_` from the plaintext header `seq` - *before* verifying the AEAD tag and never rolled it back on failure. One corrupted/forged - frame (a bit-flip on flaky wifi) shoved the high-water mark far ahead, after which every - legitimate frame was rejected as "too old" — permanently. Fixed by reordering to - replay-check → authenticate → update (RFC 3711 §3.3): the window is now touched only after - a successful tag check. Regression test in `test_media_aead.cpp` - (`test_corrupted_seq_does_not_poison_window`) — fails on the old code, passes now. - 2. **16-bit seq wrap with no rollover counter.** The wire header carried only the low 16 bits - of the nonce counter (zero-extended on receive); after 65,536 frames the reconstructed - nonce diverged and all frames failed auth. **Wire format widened to a full u64 seq** - (`voice_frame.h`: header 14 → 20 bytes, `seq` u16 → u64; `crypto.cpp`, `client.cpp`, - `media_relay.cpp` updated; `JitterBuffer::Frame::seq` widened). This is a **versioned wire - change → `VOICECAT_PROTOCOL_VERSION` 1 → 2**; the `Hello` handshake rejects on mismatch - (`conn_session.cpp`). The voice frame is parsed only in `core/`+`server/`+`tests/`, so the - Swift/C# clients need only a rebuild — no parser changes. - 3. **Server leaked UDP state on disconnect.** `SessionRegistry::unregister_session()` now also - frees `udp_endpoints_`/`udp_tokens_`/`ssrc_to_session_` (scan-and-erase by session id). - 4. **Diagnostics.** `MediaRelay` now emits rate-limited dropped-frame counters - (unmapped-endpoint / no-recv-crypto / open-failed) so a wedged media path is observable. - - **Verified:** `cmake --build --preset dev` clean; `ctest --preset dev -E external_pcm` - **22/22 pass** (incl. `m2_voice` e2e relay + the two new AEAD regressions). `external_pcm` - still aborts on the **pre-existing** CoreAudio shutdown mutex race (confirmed identical on a - clean baseline checkout under the same harness — unrelated to these changes). Docs updated: - `voice.md` §2 (header), `protocol.md` (v2 + negotiation), `security.md` (authenticate-then-advance). - -- **Done (2026-06-21):** **Expose all channel codec params + guest nickname in every client.** - - **DRED everywhere + ABI fix.** `dred` (Opus 1.6 Deep REDundancy) existed in the C ABI - (`vc_audio_config.dred`) and proto but was absent from *both* client marshaling layers — a - latent ABI mismatch: Swift `AudioConfig` and the C# `VcAudioConfigNative` blittable struct - were each one `int` short of the native struct passed to `vc_create_channel`/`vc_edit_channel`. - Added `dred` through Swift (`Models.swift`, `Marshaling.swift`, `VoiceCatClient.toNative`) and - C# (`Structs.cs`, `Models.cs`, `Marshaling.cs`, `VoiceCatClient.cs`). - - **Windows:** added the one missing DRED checkbox to `ChannelEditDialog` (all other params - were already present). - - **macOS:** `ChannelEditSheet` now exposes the previously-hidden params — application profile, - sample rate, expected packet loss, complexity, and DRED (was only stereo/bitrate/frame/FEC/DTX). - - **iOS:** `ChannelEditView` was name+topic only; rebuilt into a full create **and edit** form - (General: name/topic/parent/password/max-users/sort-order; Audio: stereo/bitrate/sample-rate/ - frame/application/packet-loss/complexity/FEC/DTX/DRED). Added `SessionState.editChannel` and an - "Edit" swipe action (admins) in `ChannelTreeView` + `ChannelBrowserView` (iOS previously had no - edit-channel UI at all). Note: the channel list doesn't carry the current audio config, so on - edit the audio fields start from codec defaults — same limitation as macOS/Windows. - - **Guest nickname.** Guests could not set a display name on iOS *or* macOS (the field was - absent/disabled; only Windows had it). Added a dedicated `nickname` to `SavedServer` on both - (backward-compatible Codable), a Nickname field shown in Guest mode (`AddServerView` / - `AddServerSheet`), and wired the guest auth path to use it (`AppState`, `ConnectWindowController`). - - **Verified:** `xcodebuild` Debug — macOS BUILD SUCCEEDED; iOS (sim, `ARCHS=arm64`) BUILD - SUCCEEDED. Core `ctest --preset dev` 22/23 (only `external_pcm` aborts on a pre-existing - shutdown mutex race; no C++ was changed). Windows C# not buildable on macOS — changes reviewed. - -- **Done (2026-06-21):** **iOS iPhone-layout UX fixes.** (1) Channels are now a **drill-down** - on iPhone — new `ChannelBrowserView` (root list of top-level channels) → `ChannelDetailView` - (people in the channel + sub-channels + an explicit "Join Channel" button with password - prompt). The iPad 3-column `NavigationSplitView` is unchanged. (2) Extracted a self-contained - `UserRow` (context menu + sheets) from `UserListView` so admin actions are reused in the - drill-down. (3) Fixed the **off-screen chat compose box**: `MainView` now places - `VoiceControlsView` via `.safeAreaInset(edge: .bottom)` instead of a floating `.overlay`, so - it reserves layout space above the tab bar and cooperates with keyboard avoidance. (4) - **Collapsed Activity into Chat** like macOS/Windows: `ChatView` renders a merged, time-sorted - timeline of `messages` + `activityLog` (activity rows in gray); the separate Activity tab and - `ActivityLogView.swift` are removed. `xcodebuild` Debug for `generic/platform=iOS` BUILD - SUCCEEDED (sim slice still arm64-only → simulator run N/A). Next: on-device check of the - drill-down + compose box + unified timeline. - -- **Done (2026-06-22):** **Windows exclude mode is now a real native exclude + self-echo - removal.** The "All apps except selected" mode previously captured the *complement of a frozen - app snapshot* in INCLUDE mode (missed late-launched apps, system sounds; wasted captures on - silent windows). It now opens a **single `ProcessLoopbackCapture` in EXCLUDE mode** - (`AUDIOCLIENT_PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE`) of the one chosen app — true - system-mix-minus-one, dynamic. `AppAudioPickerDialog` enforces single-selection in exclude - mode (the API takes one target PID). Added an **"Exclude VoiceCat's own audio (prevents echo)"** - checkbox (default on, entire-desktop only) that routes the desktop capture through the same - EXCLUDE path targeting `Environment.ProcessId`, killing the whole-device self-echo loop. - Touched `ProcessAudioMixer.cs` (`ResolveCaptures`), `AppAudioPickerDialog.cs`, `MainForm.cs`, - `AudioSessionEnumerator.cs` (`EntireDesktop(bool ExcludeSelf)`); docs in voice.md §9. No C++ / - ABI changes. `dotnet build` clean. **Still to verify on-device:** exclude actually silences - the chosen app while the rest plays, late-launched apps appear without restart, and the - self-exclude checkbox removes the echo. - -- **Done (2026-06-21):** **Screen-audio sharing on macOS + iOS.** macOS uses ScreenCaptureKit - (`ScreenAudioCapture.swift`) → `vc_stream_feed_pcm`; iOS uses a ReplayKit Broadcast Upload - Extension (`VoiceCatBroadcast`) that forwards captured `.audioApp` PCM through a shared App - Group SPSC ring (`BroadcastAudioRing.swift`) to the host's `BroadcastAudioPump`, which owns - the `SCREEN_AUDIO` stream and feeds it — single session, no creds on disk. No C++ changes - (the core was already ready). macOS `xcodebuild` Debug BUILD SUCCEEDED; iOS app + extension - build for device (the xcframework sim slice is arm64-only, so x86_64-simulator link is N/A). - Next: on-device end-to-end verification (two clients hear the shared audio; iOS broadcast - start/stop). NOTE: `ctest --preset dev` is 22/23 — `external_pcm` passes its assertions but - aborts at shutdown (`mutex lock failed`), a **pre-existing** teardown crash unrelated to this - change (no C++ was modified). - -- **Done (2026-06-21):** **macOS per-app screen-audio selection.** Before sharing, a new - `ScreenSharePickerSheet` lets the user choose scope — share Everything / Only selected apps / - All except selected apps — plus a first-class **"Exclude screen reader (VoiceOver) audio"** - toggle. `ScreenAudioCapture` now takes a `ScreenAudioSelection` and builds the matching - `SCContentFilter` (`including:` / `excludingApplications:`); app list comes from - `SCShareableContent`. macOS `xcodebuild` Debug BUILD SUCCEEDED. iOS deliberately untouched — - ReplayKit only delivers the mixed system stream, so per-app/VoiceOver filtering is impossible - there (documented in voice.md §9). **Still to verify on-device:** which process actually - carries VoiceOver speech (VoiceOver app vs. `com.apple.speech.speechsynthesisd`) — the exclude - set covers both candidates in `ScreenAudioCapture.screenReaderBundleIDs`; confirm exclusion - actually silences it in a real share. - -- **Done (2026-06-20):** **macOS client UI overhaul** — mirrors the Windows client's UI - overhaul (commit 97fa659 + 540ec13), adapted to Mac-native conventions. Also fixed and - verified the previously-uncompiled Swift changes from the external PCM feed/tap commit - (615d2a8). The main window is now just toolbar + channels + users + chat; audio device - settings (input mode, VAD, PTT key, device picker, level meter) moved to a modeless - Settings window (⌘,). Details in M5 section below. `swift test` 10/10; `xcodebuild` Debug - + Release BUILD SUCCEEDED with 0 Swift warnings. - Next: live manual verification (toolbar toggles, unified log colors, PM windows, channel - counts, volume slider, settings window); then iOS ReplayKit and macOS ScreenCaptureKit - consumers of `vc_stream_feed_pcm`. - -- **Awaiting on-device verification:** **iOS stereo mic kills headphone/A2DP output — REAL - root cause found & fixed** (2026-06-20, on Windows; verify on Mac). All prior "fixes" (the - 2026-06-19 entries below) targeted the Swift `IOSAudioRouter` on the false premise that - "miniaudio does NOT touch AVAudioSession on iOS." **It does.** The core opened its miniaudio - devices with `ma_device_init(nullptr, ...)`; with a NULL context, miniaudio (0.11.25) runs an - iOS "hack" (`miniaudio.h` ~44057) that picks a session category by device type, then - `ma_context_init__coreaudio` (~36552) calls `setCategory()` + `setActive()` on **every device - open** — capture → `AVAudioSessionCategoryRecord` with **zero options**. That wiped the - `.playAndRecord` category, the mode, and `.allowBluetoothA2DP`/`.mixWithOthers`/`.allowAirPlay` - that `IOSAudioRouter` had just configured → headphone/A2DP (and even wired) output died. The - stereo presets broke worst because they depend on the A2DP output route the wipe removed. - TeamTalk never hits this: its SDK opens RemoteIO/VPIO AudioUnits directly and leaves the - session entirely to the app (`UtilSound.swift`); miniaudio insists on managing it. - - **Fix (core, cross-platform safe):** `AudioEngine` now owns a `ma_context` built by - `make_context_config()` with `coreaudio.sessionCategory = ma_ios_session_category_none` + - `noAudioSessionActivate`/`noAudioSessionDeactivate = MA_TRUE`, and passes it to **all** - `ma_device_init` calls (playback, capture, loopback) and to `enumerate_devices`'s context. - miniaudio now never touches AVAudioSession; the Swift `IOSAudioRouter` is the sole owner - (session is already activated on connect in `AppState.swift:authResult`, before any device - opens, so removing miniaudio's self-activation is safe). Context is lazily inited in - `start()`, reused across restarts, uninited in `~AudioEngine`. - Files: `core/src/audio/audio_engine.{h,cpp}`. - - **TEMP diagnostics (remove after verification):** `AudioSessionManager.logSessionState(_:)` - logs category/mode/options/route; called after `ensureSessionActive`, on every route change, - and on `.streamStarted` (right after the core opens its devices). On Mac, watch the log when - joining voice with the Stereo Mic preset: category must stay `…PlayAndRecord` with - `allowBluetoothA2DP` and the output route must remain the headphones/A2DP device — NOT flip - to `…Record`. If confirmed, delete the `logSessionState` calls + method and the prior - band-aid comments in `IOSAudioRouter`/`audio_engine.cpp` can be trimmed. - - **Verified on Windows:** `cmake --build --preset dev` clean, `ctest --preset dev` 23/23 - (22/22 prior + `test_external_pcm` new binary). iOS build & on-device run still to be done by the user on the Mac. - -- **Done (2026-06-20):** **External PCM feed/tap API (`vc_stream_feed_pcm` + - `vc_set_pcm_sink`)** — see detail in M5 section below. `ctest --preset dev` 23/23 (was 22/22 + 1 new test binary with 3 sub-tests). - Next: iOS ReplayKit and macOS ScreenCaptureKit consumers of this API. A public, - documented API for driving audio streams with externally-provided PCM instead of (or in - addition to) miniaudio's hardware device. Motivated by four concrete use cases — all in our - roadmap — that the current "miniaudio owns the device" model can't serve: - 1. **ReplayKit Broadcast Upload Extension (iOS `SCREEN_AUDIO`)** — the extension is a - *separate process* with a ~50 MB memory cap and can't link the full `AudioEngine` - (`ma_device`, capture/playback threads). It needs to feed `CMSampleBuffer` audio (system - app audio) into the encode path without any audio hardware. The current plan in - `docs/voice.md §9` says the extension links "a minimal slice of the core (Opus encode + - media send only)" — a public feed-PCM API *is* that minimal slice. The extension links - Opus + the feed entry point, no `ma_device` needed. - 2. **ScreenCaptureKit (macOS `SCREEN_AUDIO`)** — `SCStream` delivers `CMSampleBuffer` in a - callback; convert to int16 and feed. No need to route through miniaudio's device layer. - **This is how macOS screen-audio actually gets implemented** — today it does NOT work: - `VOICECAT_HAS_LOOPBACK` is Windows-only (`core/CMakeLists.txt:88-95`), so on macOS - `AudioEngine::start_loopback_capture()` hits the `#else` stub (`audio_engine.cpp:647-649`) - and returns `false`. The macOS client's "Share Screen Audio" button - (`MainWindowController.swift:800-816`) calls `startStream(.screenAudio)` which announces - the stream to peers but captures **zero audio** — peers hear silence. The button is left - in place (not touched per user request); it'll work once this API + a ScreenCaptureKit - tap ship on Mac. - 3. **Bots** — music bot, TTS bot, radio relay, transcription bot. They create a - `SCREEN_AUDIO`/`AUX_DEVICE` stream and feed synthesized or decoded PCM via the feed API. - No audio hardware required — runs headless on a server. Today the only way to feed - external PCM is `vc_test_inject_capture` (TEST-ONLY, name signals "don't ship this") or - re-implementing Opus encode + AEAD + UDP framing yourself (~500 lines of duplicated - crypto/codec code per consumer). - 4. **Custom clients / accessibility** — soundboard, DAW integration, TTS of incoming chat, - recording/transcription of remote audio. Need either feed (send) or tap (receive) or - both. - - **What we already have (input half, gated as test-only):** `vc_test_inject_capture - (stream_id, pcm, samples)` (`voicecat.h`, `client.cpp:1452`) feeds raw int16 PCM into the - encode pipeline via `AudioEngine::inject_capture(kind, pcm, n)`. It works for any stream - kind, supports multiple concurrent injection taps (one ring buffer per local kind), and - goes through the full encode → AEAD → UDP path. The encode path already handles - `channels == 1 || 2` (proven by the WASAPI stereo loopback work, 2026-06-17 entry below). - The only problems: it's marked TEST-ONLY in the header, the name signals "don't use this in - production," and it hardcodes mono (no `channels` parameter). - - **What's missing (output half):** today decoded remote audio is mixed and pushed to the - miniaudio playback device (`on_playback`). There's no way for an external consumer to - intercept the decoded PCM of a specific remote stream — it all goes to the hardware device. - A bot that wants to record, transcribe, or re-broadcast remote audio has no hook. - - **Plan (API design — clean, append-only, no struct changes, ABI-stable):** - - **`vc_stream_feed_pcm`** — promote `vc_test_inject_capture` to a public, documented API - and add a `channels` parameter: - ```c - /* External PCM feed — replaces the hardware capture device for this stream. Caller - provides interleaved int16 PCM at the stream's sample rate. The core frames it, - encodes (Opus), seals (AEAD), and sends (UDP). Works for any stream kind - (MIC/SCREEN_AUDIO/AUX_DEVICE). The stream must be started first (vc_stream_start); - this just replaces the capture source. channels = 1 (mono) or 2 (stereo interleaved). - Thread-safe; may be called from any thread including audio callbacks. */ - 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); - ``` - - **`vc_set_pcm_sink`** — symmetric output side: receive decoded remote audio as int16 PCM - instead of (or in addition to) the hardware playback device: - ```c - /* External PCM tap — receive decoded, mixed remote audio as int16 PCM. The callback - fires on the audio thread with the mixed output for a specific remote stream. Pass - cb=NULL to disable (default: disabled, hardware playback only). When enabled, PCM is - delivered to the sink AND the hardware device (dual output) so a bot can record - without disabling local monitoring. user_id+stream_id identify the source stream. - The callback MUST NOT block — copy what you need and return (same contract as - vc_callbacks.on_event). */ - 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_result vc_set_pcm_sink(vc_client* c, vc_pcm_sink_cb cb, void* user); - ``` - - **Core changes:** - - `core/include/voicecat.h` — add `vc_pcm_sink_cb` typedef + the two function - declarations (append-only, after `vc_test_inject_capture`). Full doc comments on both - (contract, thread-safety, lifetime, use cases). - - `core/src/voicecat.cpp` — thin C trampolines → `vc_client::stream_feed_pcm` / - `set_pcm_sink`. - - `core/src/core/client.{h,cpp}` — `stream_feed_pcm`: validates `stream_id`, looks up - the `LocalStream`'s kind, calls `audio_engine_.inject_capture(kind, pcm, n)` (existing - path) with the channel count forwarded. `set_pcm_sink`: stores the callback + user - pointer; `on_playback` (or a new fan-out in the mixer) invokes it per remote stream - alongside the existing hardware write. Keep `vc_test_inject_capture` as a deprecated - alias calling `stream_feed_pcm(..., channels=1)` for source compatibility. - - `core/src/audio/audio_engine.{h,cpp}` — `inject_capture` already exists per-kind; add - a `channels` parameter to the ring-buffer write path (or a parallel stereo-aware - variant). The encode path in `client.cpp::on_capture_frame` already handles - `channels==2` via the stereo encode branch — just plumb the value through. For the - sink: add a `pcm_sink_` member (callback + user); in `on_playback` after mixing, if the - sink is set, copy the mixed PCM for the current stream and invoke the callback. The - copy must stay off the RT-critical path — document the non-blocking contract. - - **Skeleton stub path:** update `client.cpp`'s `#else` (no-deps) stub section to add - `vc_stream_feed_pcm`/`vc_set_pcm_sink` returning `VC_ERR_NOT_IMPLEMENTED` — keeps the - skeleton preset green. - - **Swift `VoiceCatCore`:** add `feedPcm(streamId:pcm:samplesPerChannel:channels:)` and - `setPcmSink(_:user:)` (the Swift wrapper around `vc_pcm_sink_cb` — a - `@convention(c)` closure + `Unmanaged` context, mirroring `Callbacks.swift`). Wraps both - new ABI functions. - - **C# `VoiceCat.Interop`:** add `StreamFeedPcm(streamId, pcm, samples, channels)` (with - `int16[]` marshaling) and `SetPcmSink` (delegates via `[UnmanagedCallersOnly]` thunk, - mirroring the event-callback pattern). Wraps both new ABI functions. - - **Tests:** - - `tests/test_external_pcm.cpp` (new) — `test_feed_pcm_round_trip`: two clients, A feeds - a known mono sine wave via `vc_stream_feed_pcm` on a MIC stream, B receives via the - normal decode path and asserts energy matches. `test_feed_pcm_stereo`: same with - `channels=2`, assert L≠R end-to-end (mirrors the WASAPI loopback stereo test). - `test_pcm_sink`: B sets a `vc_pcm_sink_cb`, A feeds PCM, assert the sink callback - receives the decoded PCM with matching energy. All headless, no audio hardware. - - `clients/apple/Tests/VoiceCatCoreTests/` — Swift wrapper round-trip for `feedPcm`. - - `clients/windows/VoiceCat.Interop.Tests/` — C# wrapper round-trip. - - **Docs:** - - `docs/architecture.md §4` — new subsection on external PCM feed/tap: the contract - (caller provides interleaved int16 at the stream's sample rate; core frames/encodes/ - seals/sends for feed; core decodes/mixes/delivers for sink; sink callback must not - block), the use cases (ReplayKit, ScreenCaptureKit, bots, custom clients), and the - relationship to `vc_test_inject_capture` (deprecated alias). - - `docs/voice.md §9` — update the iOS ReplayKit and macOS ScreenCaptureKit rows: both - now consume `vc_stream_feed_pcm` instead of a "minimal slice of the core." Update the - iOS detail bullets: the extension links Opus + `vc_stream_feed_pcm` (not a parallel - media stack). Add a macOS ScreenCaptureKit note: convert `CMSampleBuffer` → int16, - feed via `vc_stream_feed_pcm` — this is how macOS screen-audio actually ships. - - `docs/protocol.md` — no protocol changes (the feed/sink are client-local; the wire - format is identical whether PCM came from miniaudio or an external source). Note this - explicitly. - - `docs/roadmap.md` — add a milestone entry; update the iOS ReplayKit and macOS - ScreenCaptureKit pending items to reference `vc_stream_feed_pcm`. - - **Implementation order:** - 1. C ABI + core (`voicecat.h`, `voicecat.cpp`, `client.{h,cpp}`, `audio_engine.{h,cpp}`) + - skeleton stub. Verify `ctest --preset dev` green. - 2. `tests/test_external_pcm.cpp` — the three behavior tests. Verify green. - 3. Swift `VoiceCatCore` wrapper + `VoiceCatCoreTests` round-trip. - 4. C# `VoiceCat.Interop` wrapper + `VoiceCatClientSmokeTests` round-trip. - 5. Docs (`architecture.md`, `voice.md`, `protocol.md`, `roadmap.md`, header comments). - 6. **Then** ReplayKit (iOS) and ScreenCaptureKit (macOS) become ~100-line consumers of - this API instead of parallel media stacks. - - **Verification:** `ctest --preset dev` green (3 new tests); `swift test` green; `dotnet - test` green; `xcodebuild` (skeleton) green. The feed/sink tests are fully headless — no - audio hardware, no simulator, no device — so they run in CI on every platform. - - **Files to touch:** - - Core C++: `core/include/voicecat.h`, `core/src/voicecat.cpp`, - `core/src/core/client.{h,cpp}`, `core/src/audio/audio_engine.{h,cpp}`. - - Tests: `tests/test_external_pcm.cpp` (new), `tests/CMakeLists.txt`. - - Swift: `clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift`, - `clients/apple/Sources/VoiceCatCore/Callbacks.swift`, - `clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift` (new). - - C#: `clients/windows/VoiceCat.Interop/VoiceCatClient.cs`, - `clients/windows/VoiceCat.Interop/NativeMethods.cs`, - `clients/windows/VoiceCat.Interop.Tests/ExternalPcmTests.cs` (new). - - Docs: `docs/architecture.md`, `docs/voice.md`, `docs/protocol.md`, `docs/roadmap.md`. - - **ABI stability:** append-only — two new functions + one new typedef, no existing - structs/enums changed. `vc_test_inject_capture` stays as a deprecated alias for source - compatibility. Treat as a deliberate, versioned ABI event per `docs/protocol.md §8`. - - **Relationship to the iOS audio routing plan:** orthogonal. The iOS routing layer controls - *which hardware route* miniaudio opens (AVAudioSession config in Swift). This plan is about - *bypassing miniaudio's hardware entirely* (external PCM feed/tap). Both ship; they don't - conflict. ReplayKit/ScreenCaptureKit consume this API; the iOS routing layer controls the - *mic* path which still uses miniaudio's device. - ---- - -## Recent completed work - -All items below are `[x]` done; `ctest --preset dev` 26/26 on Windows after all. - -- **Per-channel sample_rate as a bandwidth cap** (2026-06-22): the channel `sample_rate` field - was inert (the codec is pinned to 48 kHz). Made it meaningful without changing the 48 kHz - clock: it's carried as `OpusParams::max_bandwidth_hz` and applied via `OPUS_SET_MAX_BANDWIDTH` - in `OpusEncoder::init` (8000→narrowband … 48000→full). Made it **channel-authoritative** on - the server (`conn_session.cpp` no longer overrides effective `sample_rate` with the client's - always-48000 request — like `frame_ms`/`mode`). `vc_get_stream_audio_config` now reports the - channel's configured rate for own streams too. New ctest `channel_samplerate`: a 7 kHz tone is - attenuated ~1000× on an 8 kHz channel vs a 48 kHz channel. Files: `opus_codec.{h,cpp}`, - `client.cpp`, `server/src/conn_session.cpp`, `docs/voice.md`, `tests/test_channel_samplerate.cpp`, - `tests/CMakeLists.txt`. (Future: a true non-48k stack is possible but unnecessary — 48 kHz is - what nearly all hard/software runs at; the bandwidth cap covers the narrowband use case.) - -- **Non-20ms channel frame_ms fix** (2026-06-22): the AudioEngine capture clock is fixed at - 48 kHz / 20 ms (960-sample frames), but a channel may set any Opus `frame_ms` (2.5…60 ms, - docs/voice.md §3) and the server enforces it unclamped. The send path handed the engine's - 960-sample frame straight to an encoder configured for the channel's window — silently - ignoring `frame_ms > 20` and **breaking `frame_ms < 20` entirely** (receiver sized its decode - buffer too small → `OPUS_BUFFER_TOO_SMALL` → dead audio). Affected the hardware mic AND - `vc_stream_feed_pcm`. Fix: `vc_client::on_capture_frame` now reframes each captured/fed block - to `ls.frame_samples` via a per-`LocalStream` accumulator (pre-sized at announce, no RT-thread - alloc) before `encode_and_send_frame`; the 20 ms case stays a zero-copy fast path. Also pinned - the codec to 48 kHz internally in `opus_params_from_audio_config` (was honoring a non-48k - effective sample_rate against a 48k PCM clock). New ctest `frame_ms_reframe` (40 ms accumulate - + 10 ms split round trips). Files: `client.{h,cpp}`, `voicecat.h` (feed doc), `docs/voice.md`, - `tests/test_frame_ms_reframe.cpp`, `tests/CMakeLists.txt`. - -- **External PCM feed/tap API** (2026-06-20): `vc_stream_feed_pcm` + `vc_set_pcm_sink` shipped. - Promotes `vc_test_inject_capture` (mono-only, TEST-ONLY) to a public, stereo-capable API. - Adds symmetric PCM sink on the playback thread. Swift wrapper (`feedPcm`/`setPcmSink` in - `VoiceCatClient.swift`, 4 XCTest smoke tests). C# wrapper (`StreamFeedPcm`/`SetPcmSink` in - `VoiceCatClient.cs` + `NativeMethods.cs`, 4 xUnit smoke tests in `ExternalPcmTests.cs`). - Three new headless C++ ctests. Docs: architecture.md §4 new subsection, voice.md §9 updated, - protocol.md §8 explicit no-protocol-change note, roadmap.md M5 entry. Files: `voicecat.h`, - `voicecat.cpp`, `client.{h,cpp}`, `audio_engine.{h,cpp}`, `tests/test_external_pcm.cpp`, - `tests/CMakeLists.txt`, Swift + C# wrappers. - -- **iOS A2DP + stereo root cause fix** (2026-06-20): miniaudio's NULL-context `ma_device_init` - was calling `AVAudioSession setCategory(Record)` on every device open, wiping the session - config `IOSAudioRouter` had set. Fixed by sharing a `ma_context` with - `sessionCategory=none` + `noAudioSessionActivate/Deactivate=MA_TRUE` — miniaudio never - touches `AVAudioSession`; `IOSAudioRouter` is the sole owner. Files: `audio_engine.{h,cpp}`. - -- **iOS audio routing overhaul** (2026-06-19): Full `IOSAudioRouter` singleton drives all - `AVAudioSession` config before miniaudio opens devices. Fixed stereo mic polar-pattern setup - (WWDC20 recipe: `setPreferredInput` + `setInputDataSource` + `.stereo` polar pattern + no - `setPreferredInputNumberOfChannels`). Added `vc_audio_restart` ABI (full stop+reinit for - close→reconfigure→reopen ordering). Added `vc_set_capture_channels` ABI (core stereo-mic - support). AVAudioSession activated proactively on `.authResult`, not lazily on - `.streamStarted`. Join/Leave Voice button added (parity with macOS). Channel-id sync fixed - (mic button was permanently dimmed). iOS deployment target raised to 18.0. - -- **iOS SwiftUI client** (2026-06-19): `VoiceCatiOS.xcodeproj` at `clients/apple/iOS/`. - Full feature parity with macOS/Windows: saved server list (JSON + Keychain, App Group - `group.cat.voice.VoiceCat`), TOFU, connect flow, channel tree, user list with context menus, - chat, admin sheets, voice controls, settings. `xcodebuild` → BUILD SUCCEEDED. - -- **macOS AppKit client** (2026-06-18): `VoiceCatMac.xcodeproj` at `clients/apple/macOS/`. - Fixed compile errors (`NSAccessibility` call-site arg order, `StreamSummary.id` vs - `.streamId`) and linker issues (`OTHER_LDFLAGS = -lc++`, `ONLY_ACTIVE_ARCH = YES` for - Release). Debug + Release both BUILD SUCCEEDED. - -- **Swift `VoiceCatCore` package + XCFramework** (2026-06-18): Shared Swift wrapper at - `clients/apple/`. `build-xcframework.sh` merges `libvoicecat.a` + 107 vcpkg static deps into - a fat `.a` via `libtool -static`. 6/6 Swift tests green (real server, mirrors C# Interop - tests). Supports macOS-arm64 + iOS-arm64 + iOS-sim slices. - -- **macOS port validated** (2026-06-18): 21/21 on macOS. Three cross-platform bugs fixed: - missing `` in POSIX test branch; SIGPIPE kills (added `SIG_IGN`); use-after-free of - Asio kqueue reactor on server shutdown (fixed `TcpAcceptor` shutdown/connection-drain - sequence). - -- **CMake preset cleanup** (2026-06-18): `m1-dev`→`dev`, `dev`→`skeleton`, `m2-dev` dropped. - New `release`, `server-release` (stripped), `apple-dev`/`apple-ios`/`apple-ios-sim`. Cross- - platform triplet auto-resolved by `cmake/voicecat-toolchain.cmake`. - -- **Disconnect, keepalive & reaper** (2026-06-18): Client sends `Ping` every 15 s; server - reaper drops sessions after 45 s; UDP `KEEPALIVE` every 5 s keeps NAT alive. `vc_disconnect` - sends graceful `Disconnect` proto. Stale-user LEFT broadcast on drop. PLC capped at ~2 s. - Three new tests: `test_disconnect_left`, `test_plc_cap`, `test_reaper_timeout`. - -- **Stereo screen-audio loopback** (2026-06-17): WASAPI loopback opens in channel's - stereo/mono mode (was hardcoded mono). Real stereo flows end-to-end through loopback → encode - → decode → mixer. New `test_loopback_stereo_capture`. - -- **Windows screen-audio UI wired** (2026-06-17): `btnScreenShareToggle` in `MainForm.cs`. - No core/proto/ABI changes — all the plumbing was already there. `dotnet test` 4/4 green. - -- **Bug fixes** (2026-06-16 – 2026-06-17): - - *AEAD nonce desync in SFU relay* — relay forwarded sender's `seq` verbatim; recipient - nonce reconstruction used the wrong counter. Fixed by rewriting the outgoing `seq` field - to the recipient's `peek_send_counter()`. - - *Playout clock free-ran* — `playout_ts` advanced even during VAD/PTT silence gaps, - eventually dropping all frames as too-late. Fixed with resync in `on_playback` via - `JitterBuffer::peek_front_ts()`. - - *Stale users after disconnect* — `ConnSession::close()` didn't broadcast `UserEvent::LEFT` - before erasing. Fixed; PLC cap added as defense-in-depth. - - *"Randomly bumped to Lobby"* — server excluded the actor from its own state-change - broadcasts. Fixed: `UserEvent::UPDATED` now goes to all clients including the actor. - - *Silent playback after join* — `opus_decode` received hardware callback frame count as - `max_samples` instead of the Opus frame size. Fixed with a decode ring buffer. - ---- - -## Milestones (see [docs/roadmap.md](docs/roadmap.md) for full detail) - -- [x] **M0 — Scaffolding** ✓ complete -- [x] **M1 — Control plane** ✓ complete (2026-06-15) -- [x] **M2 — Voice, single stream** ✓ complete (2026-06-16) -- [x] **M3 — Multi-stream & per-channel tuning** ✓ complete (2026-06-16) -- [x] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS AppKit ✓ (2026-06-18); iOS SwiftUI ✓ (2026-06-19) -- [~] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …) - ---- - -## M0 — Scaffolding ✓ - -Repo layout (`core/ server/ tools/ clients/ tests/`), CMake + vcpkg manifest, C ABI header -(`voicecat.h`), proto source of truth, core stubs for all six subsystems, `voicecat-server` + -`vccli` skeletons, smoke CTest, `.clang-format`/`.gitattributes`/`.gitignore`. - ---- - -## M1 — Control plane ✓ (completed 2026-06-15) - -**Exit criterion:** `test_m1_integration` — two clients authenticate over TLS 1.3 (guest + -Argon2id), exchange channel + private text. ~1 s. - -`FrameCodec`, `TlsContext` (mbedTLS 1.3, ECDSA-P256 self-signed, TOFU pins TLS leaf-cert -SHA-256), `WorkerPool`, `Database` (SQLite + Argon2id), `ServerIdentityManager`, -`ConnSession` state machine, `SessionRegistry`, `vc_client` full M1 C ABI, `voicecat-admin` -CLI, dual-stack `TcpAcceptor`. **Key bug fixed:** `send_frame` double-framing — `encode_envelope` -was pre-framing the protobuf; fixed by passing raw protobuf bytes. - ---- - -## M2 — Voice, single stream ✓ (completed 2026-06-16) - -**Exit criterion:** `test_m2_voice` + `test_voice_client_abi` — two headless clients auth, bind -UDP, 50 Opus frames relayed + re-encrypted by SFU, B receives ≥25 and decrypts. ~4 s. - -14-byte UDP voice header, `SodiumMediaCrypto` (ChaCha20-Poly1305 + 64-bit anti-replay), -`OpusEncoder`/`OpusDecoder` (FEC, PLC), `UdpMediaChannel`, `JitterBuffer`, `AudioEngine` -(miniaudio), `MediaRelay` SFU. **Key bug fixed:** `on_playback` passed hardware callback frame -count as `opus_decode` max_samples; fixed with a per-stream decode ring buffer. - ---- - -## M3 — Multi-stream & per-channel tuning ✓ (completed 2026-06-16) - -**Exit criterion:** `test_m3_multistream` — client A runs two concurrent streams (MIC + -SCREEN_AUDIO); B sees both; per-stream gain/mute/NR independent; effective Opus config matches -channel's server-enforced settings. ~2.4 s. - -Fixed server `stream_id` counter bug (always wrote `1`). Per-channel `AudioConfig` populated -(Lobby: mono/24kbps/VOIP + DTX; Music Room: stereo/128kbps/AUDIO). `LocalStream` map, -`pending_announce_kind_`, `run_talk_timer()`, thread-join race in `teardown_voice()` fixed. -New C ABI: `vc_get_stream_audio_config`, `vc_test_inject_capture`. - ---- - -## Post-M3 follow-up ✓ (completed 2026-06-16) - -- **Device enumeration** — `vc_list_devices`/`vc_set_input_device`; opaque hex device ids; - `vc_free_device_list` now frees. Works pre-connect. -- **VAD/PTT gate** — `EnergyVadProcessor` (RMS threshold ~0.025, 300 ms hang-time); - `vc_set_input_mode`/`vc_set_push_to_talk`; MIC-only (SCREEN_AUDIO/AUX_DEVICE bypass). -- **True stereo playback** — `playback_channels=2`; stereo decoded L→L R→R in mixer; mono - upmixed L=R; hardware fallback to mono on failure. -- **WASAPI loopback** — `loopback_device_` with `ma_device_type_loopback`; - `VOICECAT_HAS_LOOPBACK` macro (Windows-only). `vccli --share-screen-audio`. - -**Known deferred (still open):** AEC/NS/AGC (no working Windows/MSVC WebRTC APM build); -process-specific WASAPI loopback; RT-thread rule violation in `on_capture_frame` (mutex lock -on audio callback thread — pre-existing, needs lock-free ring-buffer refactor). - ---- - -## M4 — Native clients ✓ (completed 2026-06-17 – 2026-06-19) - -**Exit criterion:** `ctest --preset dev` 21/21 green; `dotnet build` 0 warnings; `xcodebuild` -BUILD SUCCEEDED (macOS + iOS); manually verified: connect, TOFU, channel tree, join, voice, -text, device pickers, level meter on each platform. - -**New C ABI (additive):** `vc_list_channels`/`vc_list_users`/`vc_list_user_streams`, -`vc_join_channel`, `VC_EVENT_SERVER_IDENTITY` + `vc_confirm_server_identity`, -`vc_config::tofu_store_path`, `VC_INPUT_ALWAYS_ON`, `vc_set_vad_threshold`, -`vc_audio_suspend`/`vc_audio_resume`, `vc_audio_restart`, `vc_set_capture_channels`. - -**Windows** (`clients/windows/`): `VoiceCat.Interop` (P/Invoke, `[UnmanagedCallersOnly]`), -`VoiceCat.App` (ConnectDialog, ServerIdentityDialog, MainForm with full M5 moderation UI, -PerUserTuningDialog, PttKeyCaptureDialog), `VoiceCat.Interop.Tests`. PTT can be system-wide -(Raw Input / WM_INPUT) or focus-scoped, toggled in Audio settings (default system-wide). - -**macOS** (`clients/apple/macOS/VoiceCatMac.xcodeproj`): NSOutlineView channel tree, -NSTableView user list, NSTextView chat, voice controls, full VoiceOver accessibility, admin -menu, 17 Swift source files. `build-xcframework.sh` produces `VoiceCatCore.xcframework`. - -**iOS** (`clients/apple/iOS/VoiceCatiOS.xcodeproj`): SwiftUI, `NavigationSplitView`/`TabView`, -`OutlineGroup` channel tree, `IOSAudioRouter` AVAudioSession driver, 24 Swift source files, -iOS 18.0 deployment target. App Group `group.cat.voice.VoiceCat` for Keychain sharing. - ---- - -## M5 — Moderation, polish, and beyond [~] (in progress 2026-06-17) - -**Exit criterion:** four ABI-level tests green (`test_m5_permissions`, -`test_m5_kick_ban_move_mute`, `test_m5_admin_accounts`, `test_m5_channel_crud`); -`vccli` can drive all moderation/admin/channel operations against a live server. - -- [x] **Server-side moderation & permissions** — per-session `Permissions`, kick/ban/move/ - server-mute, channel CRUD, DB schema v2 (`channels`, `bans`), BLAKE2b channel passwords. -- [x] **C ABI** — `vc_kick_user`, `vc_ban_user`, `vc_set_permission`, `vc_set_server_mute`, - `vc_move_user`, `vc_create_channel`, `vc_edit_channel`, `vc_delete_channel`, - `vc_create_account`, `vc_reset_password`, `vc_delete_account`, `vc_list_accounts`, - `vc_get_permissions`; events `VC_EVENT_GENERIC_RESULT`, `VC_EVENT_ACCOUNT_LIST`. -- [x] **Four M5 tests** passing — `ctest --preset dev` 21/21. -- [x] **vccli** M5 flags: `--kick`, `--ban`, `--move`, `--server-mute`/`-unmute`/`-deafen`/ - `-undeafen`, `--set-permission`, channel CRUD, account CRUD, `--username`/`--password`. -- [x] **All three client UIs** (Windows WinForms, macOS AppKit, iOS SwiftUI) expose the full - M5 moderation and admin surface. -- [x] **Docs** — `docs/protocol.md`, `docs/security.md` kept in sync. -- [x] **DRED/audio-quality polish** — done (2026-06-20). `bool dred` added to `AudioConfig` - proto (field 11) and `vc_audio_config` C ABI. Encoder: `OPUS_SET_DRED_DURATION(2)` when - enabled (20 ms of ML redundancy per packet). Decoder: `OpusDREDDecoder` + per-stream - `OpusDRED` scratch pre-allocated; `JitterBuffer::try_copy_front_payload` peeks at the next - buffered packet on every PLC step; if DRED data is present, `opus_decoder_dred_decode` - reconstructs the lost frame — otherwise falls back to standard PLC. New test: - `test_dred_toggle` (ctest 22/22). Files: `voicecat.proto`, `voicecat.h`, - `opus_codec.{h,cpp}`, `audio_engine.{h,cpp}`, `client.cpp`, `session.{h,cpp}`. -- [x] **In-band FEC decoder wiring** — done (2026-06-22). The encoder set `OPUS_SET_INBAND_FEC` - all along, but the decoder never invoked it — the loss path went DRED → PLC, so FEC redundancy - was emitted (and paid for in bitrate) but never consumed. Wired the FEC recovery into - `AudioEngine::on_playback`'s loss branch between DRED and PLC: copy the next buffered packet - once, try DRED, else (if the stream negotiated FEC) `decode(next_pkt, …, fec=true)`, else PLC. - Added per-stream `RemoteStream::fec_enabled_`, captured from `OpusParams` in - `init_recv_stream`. Recovery priority is now **DRED → FEC → PLC**. ctest 27/27 green. Files: - `audio_engine.{h,cpp}`, `docs/voice.md`. -- [ ] **DRED toggle in client UIs** — expose the `dred` flag in all three channel-config UIs - so admins can enable it per channel. Windows: `ChannelEditForm` / `vc_channel_info.audio.dred` - checkbox. macOS AppKit: channel-edit sheet. iOS SwiftUI: channel-edit form. All three UIs - already have full channel CRUD wired; this is an additive checkbox on the existing audio-config - section. (Core/protocol/ABI all done — this is UI-only work.) -- [x] **macOS ScreenCaptureKit screen-audio** — done 2026-06-21. `ScreenAudioCapture.swift` - drives an `SCStream` (audio-only, `excludesCurrentProcessAudio`), converts Float32 → - int16 in the channel's mono/stereo mode, and calls `vc_stream_feed_pcm`. Capture starts on - the self `.streamStarted` event (when the effective config is known); wired into - `MainWindowController.screenAudioClicked()`. -- [x] **iOS ReplayKit Broadcast Extension** (`VoiceCatBroadcast`) — done 2026-06-21. - Forward-to-host design: the extension (`SampleHandler.swift`) captures `.audioApp`, - converts to 48 kHz int16 stereo, and writes a shared App Group SPSC ring - (`BroadcastAudioRing.swift`); the host's `BroadcastAudioPump` owns the `SCREEN_AUDIO` - stream and feeds via `vc_stream_feed_pcm` (single session, no creds on disk). UI is an - `RPSystemBroadcastPickerView` in `VoiceControlsView`. (Replaced the speculative - `BroadcastCredentials.swift` self-connecting design, now removed.) -- [x] **External PCM feed/tap API** (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) — done - 2026-06-20. Promotes `vc_test_inject_capture` (mono-only, TEST-ONLY) to a public API with - stereo support. Adds a symmetric PCM sink fired on the playback thread per decoded remote - stream. Full wrappers for Swift (`feedPcm`/`setPcmSink`) and C# (`StreamFeedPcm`/ - `SetPcmSink`). Three new C++ ctests (`test_feed_pcm_round_trip`, `test_feed_pcm_stereo`, - `test_pcm_sink`), 4 Swift XCTest smoke tests, 4 C# xUnit smoke tests. Docs updated - (architecture.md §4 new subsection, voice.md §9 updated, protocol.md §8 explicit - no-protocol-change note, roadmap.md M5 entry). `ctest --preset dev` 23/23. -- [x] **macOS client UI overhaul** — done 2026-06-20. Mirrors the Windows client's UI - overhaul (toolbar, unified log, PM windows, channel counts, output volume, keyboard - shortcuts), adapted to Mac-native conventions: - - **NSToolbar**: Join Voice, Share Screen Audio, Mute, Deafen (SF Symbol toggle buttons), - and Output Volume slider (NSSlider 0–100, default 80). Voice actions + mute/deafen + - output volume moved out of the bottom voice panel into the toolbar. Bottom panel keeps - input-mode segmented control / VAD slider / PTT key / device picker / level meter. - - **Unified log**: chat `NSTextView` + activity `NSTableView` collapsed into a single - `NSTextView` — activity events in `secondaryLabelColor` (gray), chat in default color. - Removed `activityTableView` and `activityLog` array. - - **Private messaging**: scope dropdown removed; compose bar always sends to the current - channel. Each PM conversation opens in its own modeless `PrivateMessageWindowController` - (NSWindow). Incoming `.textMessage` with `.private` scope routed to the right window; - outgoing PMs echoed by server arrive through the same path. "Send Private Message…" - added to user context menu. "New Private Message…" (⌘⇧N) opens `UserPickerSheet` - listing all server users. - - **Channel counts**: outline view renders `"Name (n)"` with live user counts; - `refreshChannelTree()` called on `.userJoined`/`.userLeft` (was missing). - - **Voice menu** (⌘⇧V join/leave, ⌘⇧S share screen, ⌘⇧M mute, ⌘⇧D deafen) and **Messages - menu** (⌘⇧N new PM) added to `NSApp.mainMenu` via `NSMenuItem` key equivalents with - `[.command, .shift]` mask. Removed on `windowWillClose`. Mac-native: ⌘ not Ctrl, dispatched - by the responder chain (no custom key monitor needed). - - **Output volume**: `setOutputVolume(_:)` wrapper added to `VoiceCatClient.swift` (was - missing — the C ABI + C# wrapper shipped in commit 97fa659 but the Swift wrapper was - never added). Wired end-to-end: toolbar slider → `client.setOutputVolume(gain)`. - - **Part A (uncompiled Swift fix)**: the external PCM feed/tap Swift wrapper (commit - 615d2a8) was never compiled — the local xcframework predating the `voicecat.h` PCM - additions. Fixed: rebuilt xcframework (regenerated module map), fixed `UInt`→`Int` type - mismatch in `feedPcm` (Swift imports `size_t` as `Int` not `UInt`), added - `VoiceCatPcmSinkCallback` typealias (Swift-idiomatic alias for the C `vc_pcm_sink_cb` - so consumers don't need to directly import `VoiceCatC`). `swift test` 10/10 green. - - **Audio settings moved to Settings window**: the bottom voice panel (input mode, VAD - slider, PTT key, device picker, level meter) was removed from the main window and moved - into a new `SettingsWindowController` — a modeless window opened via the app menu's - "Settings…" (⌘,) item. The main window is now just toolbar + channels + users + chat. - Source-of-truth for audio settings (`selectedInputMode`, `vadThresholdValue`, - `selectedInputDeviceId`, `pttKeyCode`) lives in `MainWindowController` so voice start can - apply them even before the settings window has been opened; `SettingsWindowController` - reads from and writes back to those properties and applies changes to the client - immediately when voice is active. The level meter is forwarded from - `MainWindowController.handleLevel` → `settingsWindowController.updateLevel(rms:)`. - `keyCodeName` helper deduplicated (was duplicated in `PttKeyCaptureSheet.swift` + - `MainWindowController.swift` — now shared from `MainWindowController.swift`). - - Files: `MainWindowController.swift` (overhauled), `PrivateMessageWindowController.swift` - (new), `UserPickerSheet.swift` (new), `SettingsWindowController.swift` (new), - `VoiceCatClient.swift` (setOutputVolume + VoiceCatPcmSinkCallback typealias + feedPcm - type fix), `ExternalPcmTests.swift` (use typealias), `PttKeyCaptureSheet.swift` (removed - duplicate `keyCodeName`), `VoiceCatMac.xcodeproj/project.pbxproj` (register 3 new files). - - **Platform-specific adaptations** (vs. Windows): `NSToolbar` instead of `ToolStrip`; - global menu bar + `NSMenuItem` key equivalents (⌘ not Ctrl, responder-chain dispatched); - PM windows as modeless `NSWindow`s; picker as Mac sheet; gray = `secondaryLabelColor`; - SF Symbols for toolbar icons. - ---- - -## Decisions log - -All architecture/scope decisions are settled and recorded in -[docs/roadmap.md §2 "Resolved decisions"](docs/roadmap.md) and reflected across `docs/`. -If you make a *new* decision, record it there and link it here. - ---- - -## How to update this file - -1. Check off tasks as you complete them; flip a milestone to `[x]` only when its **exit - criterion test** passes. -2. Keep the **"Where we left off / next action"** block at the top accurate — it's the first - thing the next agent reads. -3. When you start a milestone, copy its task list from `docs/roadmap.md` into a section here. +Keep this file short. It records only current state, open release gates, and the immediate +cleanup queue. Git history is the implementation diary. diff --git a/README.md b/README.md index a92ed74..0e07ac3 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,45 @@ # VoiceCat -Self-hosted, native voice & text chat in the spirit of classic TeamSpeak / Mumble — -channel-based voice, channel + private text, one server you run yourself. Plain **TCP** -(control) and **UDP** (media), no WebRTC. Encrypted by default. A shared **C++ core** -(`libvoicecat`) drives native clients (Swift on macOS/iOS, C# on Windows) and the server. +VoiceCat is a self-hosted, channel-based voice and text chat system built on .NET 10. It uses +TLS 1.3 for protobuf control traffic and authenticated encrypted UDP for Opus media. There is +no WebRTC, central directory, or plaintext mode. -> **Status:** Design complete in [`docs/`](docs/). M1–M5 are implemented — real TLS control -> plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree, -> permissions, moderation, disconnect/keepalive/reaper. Windows WinForms C# client shipped -> (M4). macOS/iOS Swift client is next. See [`PROGRESS.md`](PROGRESS.md) and -> [`docs/roadmap.md`](docs/roadmap.md). - -## Read the design first - -The [`docs/`](docs/) folder is the source of truth. Start at [`docs/README.md`](docs/README.md), -then `architecture` → `protocol` → `voice` → `security` → `tech-stack` → `deployment` → -`roadmap`. +The repository contains a managed server, CLI, shared client/audio core, and native Windows, +macOS, and iOS user interfaces. A small C library supplies Opus/RNNoise, and a small Swift iOS +extension captures ReplayKit application audio. ## Build -The default development preset is **`dev`** — it builds everything (server + tools + tests) -with real vcpkg deps. It works on Windows, Linux, and macOS (vcpkg triplet auto-resolved). - ```bash -# one-time vcpkg setup (bundled as a submodule, pinned to vcpkg.json's builtin-baseline): -git submodule update --init vcpkg -./vcpkg/bootstrap-vcpkg.sh # .bat on Windows - -# configure + build + test: -cmake --preset dev -cmake --build --preset dev -ctest --preset dev # 21 behavior tests +./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 ``` -To use an external vcpkg checkout instead, set `VCPKG_ROOT=/path/to/vcpkg` (or -`$env:VCPKG_ROOT` on PowerShell) — it always takes priority over the bundled submodule. - -Artifacts land in `build/dev/bin/` (`voicecat-server`, `vccli`, `voicecat-admin`). - -The `skeleton` preset (no vcpkg deps, stubs only) is a fast smoke check that needs no -third-party libraries: - -```bash -cmake --preset skeleton && cmake --build --preset skeleton && ctest --preset skeleton -``` - -See [`docs/building.md`](docs/building.md) for the full preset matrix (including `release`, -`server-release`, `windows-client`, and Apple platform scaffolding). +See [CLAUDE.md](CLAUDE.md) for the developer map, [docs/README.md](docs/README.md) for current +contracts, and [PROGRESS.md](PROGRESS.md) for the short release handoff. ## Layout -``` -docs/ design spec (read this) -core/ libvoicecat — the shared C++ core - include/ voicecat.h (the C ABI all clients call) - proto/ voicecat.proto (control-plane wire format, source of truth) - src/ net/ crypto/ codec/ protocol/ session/ audio/ (stubs today) -server/ voicecat-server (headless; links the core) -tools/vccli/ headless test client — drives the protocol from M1 on -clients/ apple/ (Swift, M4) windows/ (C#, M4) — placeholders for now -tests/ CTest targets +```text +proto/ protobuf wire schema +dotnet/src/ managed protocol, crypto, server, client, audio, and CLI +dotnet/tests/ managed behavior and integration tests +clients/windows/ WinForms client +clients/apple/dotnet/ 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 ``` -## License +The remaining C++ implementation and old Swift applications are unsupported retirement +sources. They are not compatibility targets or architectural authorities. -Permissive-only dependencies (no GPL/LGPL) so the project can be redistributed freely, -including closed-source. Project license: TBD (see [`docs/tech-stack.md`](docs/tech-stack.md) §5). +## Non-negotiable constraints + +- Encryption is mandatory. +- No GPL or LGPL dependencies. +- Real-time audio callbacks never allocate, lock, block, or perform I/O. +- Accessibility is a release requirement on every client platform. diff --git a/clients/apple/README.md b/clients/apple/README.md index c019651..25ab867 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -107,7 +107,7 @@ scripts/build-xcframework.sh --all 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 (`third_party/rnnoise/`, built as a CMake +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` diff --git a/clients/apple/dotnet/README.md b/clients/apple/dotnet/README.md index 4e934f5..96fc971 100644 --- a/clients/apple/dotnet/README.md +++ b/clients/apple/dotnet/README.md @@ -4,14 +4,14 @@ The managed client now implements the Swift client's functional surface: profiles and Keychain authentication, TOFU, protected channels, hierarchical channel presentation and roster state, channel and modeless private text, microphone/auxiliary/screen-audio streams, selectable Core Audio devices, VAD/PTT/always-on input, stereo microphone, RNNoise, per-stream receive tuning, self/server mute and deafen, full channel configuration, moderation, permissions, account administration, event sounds and speech. It imports the legacy Swift profile, TOFU and Keychain state during cutover. Capture is converted to interleaved 48 kHz int16 PCM through `AVAudioConverter`; playback converts the shared bounded `PcmRing` into Core Audio's native planar Float32 layout inside an allocation-free, non-blocking `AVAudioSourceNode` callback. -The Swift app remains the release oracle until the managed app passes the manual VoiceOver and real multi-human call matrix. Produce and validate an ad-hoc Release bundle with `zsh clients/apple/dotnet/publish-macos.sh --dry-run`. The script prefers `/usr/local/share/dotnet/dotnet`, where the pinned Apple workload is installed; set `VOICECAT_DOTNET` to override that host. Signing is performed on `bin/Release/distribution/VoiceCat.app`, leaving MSBuild's incremental app bundle untouched. For distribution, set `VOICECAT_CODESIGN_IDENTITY`; setting `APPLE_ID`, `APPLE_TEAM_ID`, and `APPLE_APP_PASSWORD` additionally submits, staples, and Gatekeeper-validates the notarized bundle. +The managed clients are the supported implementation. Manual VoiceOver and real multi-human call validation remain release gates. Produce and validate an ad-hoc Release bundle with `zsh clients/apple/dotnet/publish-macos.sh --dry-run`. The script prefers `/usr/local/share/dotnet/dotnet`, where the pinned Apple workload is installed; set `VOICECAT_DOTNET` to override that host. Signing is performed on `bin/Release/distribution/VoiceCat.app`, leaving MSBuild's incremental app bundle untouched. For distribution, set `VOICECAT_CODESIGN_IDENTITY`; setting `APPLE_ID`, `APPLE_TEAM_ID`, and `APPLE_APP_PASSWORD` additionally submits, staples, and Gatekeeper-validates the notarized bundle. Build on Apple Silicon macOS 27 with Xcode 27, .NET SDK 10.0.401 and workload set 10.0.401. Homebrew `protobuf` supplies a native arm64 `protoc`; the current `Grpc.Tools` package contains only an x64 macOS compiler. ```bash sudo dotnet workload install macos ios --version 10.0.401 brew install protobuf # if /opt/homebrew/bin/protoc is not already present -cmake -S dotnet/native -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release -DVOICECAT_DOTNET_RID=osx-arm64 +cmake -S native/media -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release -DVOICECAT_DOTNET_RID=osx-arm64 cmake --build dotnet/artifacts/native-build --config Release --target voicecat_media --parallel 2 cmake --install dotnet/artifacts/native-build --config Release --component DotnetMedia --prefix dotnet/artifacts/native dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx @@ -29,7 +29,10 @@ dotnet build clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj -c Debug -r i For a physical device, use the checked-in build and deployment wrappers. The iPhone must be paired and trusted, and Xcode must have an Apple Development identity and provisioning profile for both `me.iamtalon.voicecat` and `me.iamtalon.voicecat.broadcast`. Let automatic signing -select them, or set `VOICECAT_CODESIGN_KEY` and `VOICECAT_CODESIGN_PROVISION` before building. +select them, or set `VOICECAT_CODESIGN_KEY`, `VOICECAT_CODESIGN_PROVISION`, and +`VOICECAT_DEVELOPMENT_TEAM` before building. The development team is required by the retained +ReplayKit extension's Xcode build. Set `VOICECAT_ALLOW_PROVISIONING_UPDATES=1` only when Xcode +needs to create or download a profile. ```bash clients/apple/dotnet/build-ios-device.sh --configuration Debug diff --git a/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj b/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj index 5ac6eed..122afb1 100644 --- a/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj +++ b/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj @@ -20,7 +20,7 @@ - +