Files
voice-cat/docs/porting-to-dotnet.md
Talon c6c003b8a7 docs: add the .NET/C# porting plan
Proposal for replacing the C++ core, C++ server, and Swift macOS/iOS
clients with a single .NET 10 / C# codebase.

Covers the dependency map (8 vcpkg deps + 1 vendored -> 3 native libs),
the real-time-audio design, per-client strategy, a test-porting plan for
all 29 ctest cases, doc-sync work, an 11-phase migration, and a risk
register.

Two findings drive the shape of the plan:

- SslStream has no RFC 5705 keying-material exporter, which the media
  AEAD key derivation depends on (docs/security.md 2). The API is an
  unapproved proposal and SChannel structurally cannot export secrets.
  Recommends BouncyCastle's managed TLS 1.3 stack, which does implement
  the exporter and keeps the wire format byte-compatible with the C++
  implementation -- so the existing tree stays usable as a conformance
  oracle throughout the port. Protocol-v3 in-band media keys documented
  as the fallback.

- The iOS ReplayKit broadcast upload extension stays in Swift: 50 MB
  jetsam cap plus an unsupported extension type in .NET for iOS, and it
  already doesn't link the core. Leaves one Swift file plus the shared
  App Group ring.

Indexed in docs/README.md. Nothing here is implemented yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 03:19:59 +02:00

826 lines
51 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Porting VoiceCat to pure .NET / C#
**Status:** proposal / plan. Nothing here is implemented yet.
**Target runtime:** .NET 10 LTS (in-service to Nov 2028), with .NET 11 as the follow-on.
**Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the
Swift macOS/iOS clients with a single C# codebase. The Windows WinForms client is already C#
and is mostly *kept*.
This document is the map for that work: what maps 1:1, what has no .NET equivalent, what has
to stay native, and the order to do it in so the tree is testable at every step.
---
## 0. Executive summary
**The port is feasible.** Roughly 80 % of the C++ core is protocol/state-machine/buffer code
that translates to C# almost mechanically and gets *shorter*. The risk is concentrated in
four places, and only one of them is a genuine design change:
| Risk | Verdict |
|------|---------|
| **TLS keying-material exporter (RFC 5705)** — the media-key derivation the whole UDP path depends on | ⚠️ **`SslStream` cannot do this.** [The API is an unapproved proposal](https://github.com/dotnet/runtime/issues/112529) targeting "Future", and SChannel structurally can't export secrets. **Must** use BouncyCastle's managed TLS stack, or change the protocol. See §3. |
| **Real-time audio + GC** | Manageable, but needs deliberate design. Native audio callbacks must not enter managed code. See §5. |
| **Opus 1.6 / DRED, RNNoise** | No managed equivalent exists. Stay native via P/Invoke. See §4. |
| **iOS ReplayKit Broadcast Upload Extension** | ⚠️ **Keep this in Swift.** 50 MB jetsam cap + a managed runtime in an appex that .NET for iOS does not officially support. See §8.4. |
Everything else — protobuf, SQLite, sockets, Argon2id, ChaCha20-Poly1305, X.509 generation,
jitter buffers, the mixer, the session model, the server — is either built into .NET or
covered by a permissive, well-maintained NuGet package.
**Net effect on native dependencies:** from 8 vcpkg deps + 1 vendored, down to **3 native
libraries** (libopus, RNNoise, miniaudio) — all three tiny, all three already vendored or
trivially buildable, and all three optional to *replace* later.
---
## 1. What exists today (baseline inventory)
Sizes are source bytes, to calibrate effort.
### Core — `core/` (~310 KB C++)
| File | Bytes | What it is | Port difficulty |
|------|-------|-----------|-----------------|
| `core/src/core/client.cpp` + `.h` | 107 K | `vc_client` — the entire client state machine: connect/auth/TOFU, channel + user model, stream lifecycle, reframing, encode path, event queue | **Medium**, but large. Mostly mechanical. |
| `core/src/audio/audio_engine.cpp` + `.h` | 69 K | miniaudio devices, `JitterBuffer`, per-ssrc decode, DRED/FEC/PLC ladder, mixer, level meters, talk detection, external feed/tap/mixed-sink | **Hard** — the RT-sensitive part |
| `core/src/net/transport.cpp` + `.h` | 25 K | Asio TCP framing `[u32 len][payload]`, UDP socket | **Easy**`Socket`/`System.IO.Pipelines` is nicer |
| `core/src/crypto/` | 27 K | mbedTLS TLS 1.3 wrapper, exporter, self-signed cert gen, Ed25519 identity, ChaCha20-Poly1305 + anti-replay, TOFU pin store | **Hard** — see §3 |
| `core/src/codec/opus_codec.*` | 9 K | libopus encode/decode wrapper incl. DRED | **Easy** — P/Invoke shim |
| `core/src/protocol/`, `core/src/session/` | 12 K | Envelope (de)serialize, dispatch, channel/user/stream registry | **Easy** |
| `core/src/audio/apm_processor.*` | 5 K | RNNoise wrapper + energy VAD | **Easy** |
| `core/src/voicecat.cpp` | 13 K | C ABI façade over `vc_client` | **Deleted** — no ABI needed any more |
| `core/include/voicecat.h` | 27 K | The C ABI | **Becomes a C# interface**, not an ABI |
| `core/proto/voicecat.proto` | 9 K | Wire format, source of truth | **Unchanged** |
### Server — `server/` (~80 KB C++)
`conn_session.cpp` (34 K, per-connection protocol handling), `db.cpp` (26 K, SQLite:
accounts, channels, bans), `session_registry.cpp` (20 K), `media_relay.cpp` (8 K, the SFU
relay), `server.cpp`, `identity.cpp`. All **easy-to-medium** — this is ordinary async network
server code and translates very well.
### Clients
| Client | Today | After the port |
|--------|-------|----------------|
| Windows | C#, WinForms, `net10.0-windows`, ~40 files | **Kept.** Swap `VoiceCat.Interop` P/Invoke for a direct project reference. |
| macOS | Swift + AppKit, `MainWindowController.swift` alone is 70 K | Rewrite as C# AppKit on `net10.0-macos` — near-mechanical, AppKit maps 1:1 |
| iOS | Swift + SwiftUI, ~150 K across views/audio | Rewrite as C# UIKit on `net10.0-ios` (or MAUI — see §8.3). **No 1:1 SwiftUI equivalent.** |
| iOS broadcast appex | Swift, 12 K (`SampleHandler.swift` + `BroadcastAudioRing.swift`) | **Stays Swift.** See §8.4 |
| `tools/vccli` | C++, 32 K | Rewrite as a C# console app — this becomes the primary conformance harness |
### Tests — `tests/` (29 files, ~290 KB, all green)
These are the real specification. Every one of them must be ported to xUnit and stay green;
the milestone exit criteria in `docs/roadmap.md` are encoded here.
---
## 2. Target solution layout
```
voice-cat/
├── proto/voicecat.proto # unchanged, single source of truth
├── native/ # the only C left
│ ├── opus/ # libopus 1.6 build scripts
│ ├── rnnoise/ # moved from third_party/
│ ├── miniaudio/ # miniaudio.h + voicecat_audio_shim.c (§5.2)
│ └── build-native.{ps1,sh} # produces per-RID binaries
├── src/
│ ├── VoiceCat.Protocol/ # Google.Protobuf codegen + Envelope framing
│ ├── VoiceCat.Crypto/ # TLS, media AEAD, anti-replay, identity, TOFU store
│ ├── VoiceCat.Codec/ # libopus P/Invoke + OpusEncoder/OpusDecoder
│ ├── VoiceCat.Dsp/ # RNNoise P/Invoke, energy VAD, resample helpers
│ ├── VoiceCat.Audio/ # devices, jitter buffer, mixer, RT ring buffers
│ ├── VoiceCat.Core/ # VoiceCatClient — replaces vc_client + the C ABI
│ ├── VoiceCat.Server/ # replaces server/
│ ├── VoiceCat.Cli/ # replaces tools/vccli + voicecat-admin
│ └── clients/
│ ├── VoiceCat.Windows/ # net10.0-windows, WinForms (kept, retargeted)
│ ├── VoiceCat.Mac/ # net10.0-macos, AppKit
│ ├── VoiceCat.iOS/ # net10.0-ios, UIKit
│ └── VoiceCatBroadcast/ # Swift appex — the one non-C# artifact
└── tests/VoiceCat.Tests/ # xUnit, ports all 29 ctest cases
```
**Target frameworks.** `VoiceCat.Core` and everything below it target plain `net10.0` — no
platform TFM — so the same assembly loads into the server, the WinForms app, the macOS app,
the iOS app, and the test host. Only the four leaf client projects carry a platform TFM.
**Why not one big assembly:** the layering is what keeps the "core owns audio, UI is thin"
rule enforceable. It also lets the server reference `VoiceCat.Protocol` + `VoiceCat.Crypto`
without dragging in miniaudio.
---
## 3. The TLS problem — read this before anything else
### 3.1 What breaks
`docs/security.md` §2 is built on one mbedTLS call:
```c
mbedtls_ssl_export_keying_material("voicecat media v1", ...) media keys
```
**.NET has no equivalent.** `SslStream` exposes no RFC 5705 exporter. The API proposal
([dotnet/runtime#112529](https://github.com/dotnet/runtime/issues/112529)) is labelled
`api-suggestion` ("NOT ready for implementation"), milestone *Future*, and the platform notes
on it are discouraging: *"Windows needs verification"* (SChannel runs TLS in a separate
privileged process and deliberately refuses to hand back secrets), *"OSX Not implemented
for Secure Transport"*. Do not plan around this shipping.
This is not a small dependency swap. The media key derivation is the root of the entire UDP
path: AEAD keys, nonce discipline, anti-replay, and the UDP binding token all hang off it.
### 3.2 Option A (recommended) — BouncyCastle managed TLS
[BouncyCastle for .NET](https://www.nuget.org/packages/BouncyCastle.Cryptography) (MIT,
actively maintained) ships a **complete managed TLS 1.3 client and server** in
`Org.BouncyCastle.Tls`, and `TlsContext` exposes exactly the method we need:
```csharp
byte[] ExportKeyingMaterial(string asciiLabel, byte[] contextValue, int length);
// "Export keying material according to RFC 5705" — TLS 1.3 (RFC 8446 §7.5) aware
```
*(verified against [bc-csharp `crypto/src/tls/TlsContext.cs`](https://github.com/bcgit/bc-csharp/blob/master/crypto/src/tls/TlsContext.cs); the C# port is feature-matched to bc-java here.)*
**Consequences — mostly good:**
-**Byte-identical wire compatibility with the existing C++ implementation.** This is the
single biggest de-risking factor in the whole project: it means a .NET client can talk to
the shipped C++ server (and vice versa) at *every* step of the port, and the C++ side
becomes a conformance oracle. See §11.
-**Identical TLS behaviour on all five platforms.** No SChannel-vs-OpenSSL-vs-
SecureTransport variance, no per-OS cipher-suite policy surprises, no "TLS 1.3 on macOS
only since .NET 10" caveat. For a self-hosted product shipping to unknown machines this is
worth a lot on its own.
- ✅ MIT, permissive — satisfies the hard no-GPL rule.
- ✅ Also gives us **Ed25519** and **BLAKE2b** (see §4), which .NET lacks.
**Costs:**
- ⚠️ Pure-managed TLS is slower than SChannel/OpenSSL. **This does not matter here** — the
TLS channel carries only control messages (a handshake plus a few KB/s of protobuf). Media
is UDP + ChaCha20-Poly1305, which uses the fast built-in .NET AEAD, not BouncyCastle.
- ⚠️ You implement `TlsClient` / `TlsServer` callback subclasses yourself (~200300 lines for
both sides): cipher-suite selection, certificate handling, `TlsCrypto` provider. Well-trodden
— BC ships `DefaultTlsClient`/`DefaultTlsServer` bases and `BcTlsCrypto`.
- ⚠️ You own the cert-validation logic (that's actually a *plus* for TOFU — see §3.4).
- ⚠️ One more external dependency in the trust base. It's Bouncy Castle; acceptable.
### 3.3 Option B — `SslStream` + in-band media keys (protocol v3)
Abandon the exporter. Since the TLS 1.3 channel is already confidential, authenticated and
forward-secret, the server can simply **generate the media keys and send them inside it**:
```proto
message AuthResult {
// ...
bytes udp_token = 6;
bytes media_key_c2s = 7; // 32 bytes, server-generated CSPRNG (NEW, v3)
bytes media_key_s2c = 8; // 32 bytes (NEW, v3)
}
```
This is what Mumble does (its OCB2/AES key exchange happens inside its TLS control channel),
and it is what SDES-SRTP-over-TLS does. Security posture is equivalent: an attacker who can
read these can already read everything.
- ✅ Uses only built-in `System.Net.Security.SslStream` — zero TLS dependencies.
- ✅ Simplest possible code; best per-platform TLS performance.
-**Breaks wire compatibility** → no cross-testing against the C++ implementation during
the port, which throws away the best safety net available.
- ❌ Protocol version bump to 3, forced-update for the shipped iOS/macOS/Windows clients.
- ❌ Loses the exporter's nice property that media keys are never *transmitted* at all.
- ⚠️ Server-side `SslStream` on Windows has a real footgun: a cert created with
`CertificateRequest.CreateSelfSigned` must be round-tripped through
`X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null)` before SChannel
will accept it — an ephemeral/CNG-only key produces a confusing handshake failure.
### 3.4 Recommendation
**Take Option A (BouncyCastle) for the port. Keep Option B as an optional later
simplification** once the C++ tree is retired and you no longer need it as an oracle — at
which point it's a contained protocol-v3 change, not a rewrite.
TOFU is *easier* under Option A: BouncyCastle hands you the peer's DER certificate chain
directly in `TlsAuthentication.NotifyServerCertificate`, so
`SHA256.HashData(leafDer)` — the exact value `docs/security.md` §1.1 says is pinned — falls
out with no ceremony. (Under `SslStream` you'd get it from
`RemoteCertificateValidationCallback` via `cert.GetRawCertData()`; also fine, just less
direct.)
**Worth fixing while you're in here:** `security.md` §1.1 documents a known limitation — the
Ed25519 identity key is not bound to the TLS cert, so it's display-only. When generating the
self-signed cert in C#, **embed the Ed25519 public key as a X.509 extension or SAN URI**.
`CertificateRequest.CertificateExtensions` makes this trivial, and it closes the gap the doc
has been carrying.
---
## 4. Dependency map — C++ → .NET
| Concern | Today | .NET replacement | License | Notes / pitfalls |
|---------|-------|------------------|---------|------------------|
| Sockets, timers | Asio | **`System.Net.Sockets`** + `System.IO.Pipelines` + `PeriodicTimer` | built-in | Strictly better. `Socket.ReceiveFromAsync(SocketAddress)` (net8+) is the allocation-free UDP receive path — use it, not `UdpClient`. |
| Control framing | hand-rolled `[u32 len]` | `System.IO.Pipelines` `SequenceReader` | built-in | Removes a class of bugs. Keep the same 16 MiB frame cap. |
| TLS 1.3 | mbedTLS | **BouncyCastle `Org.BouncyCastle.Tls`** | MIT | See §3. **Not `SslStream`.** |
| Media AEAD | libsodium ChaCha20-Poly1305 | **`System.Security.Cryptography.ChaCha20Poly1305`** | built-in | ⚠️ Check `ChaCha20Poly1305.IsSupported` at startup — it is OS-backed (Windows 10 1903+ / OpenSSL 1.1+). Fall back to BouncyCastle's `ChaCha20Poly1305` if false. Same 12-byte nonce, 16-byte tag → identical wire bytes. |
| Anti-replay window | hand-rolled 64-bit | port verbatim | — | ~40 lines. Keep the RFC 3711 §3.3 ordering (replay-check → authenticate → *then* advance). This ordering is load-bearing; `test_media_aead.cpp` covers it. |
| Argon2id | libsodium `crypto_pwhash` | **`Konscious.Security.Cryptography.Argon2`** | MIT | Pure managed. ⚠️ **Existing password hashes will not verify** — libsodium emits `$argon2id$...` PHC strings with its own tuned m/t/p. Either implement a PHC-string parser and feed those params to Konscious (doable, recommended), or force a password reset on migration. Decide early; `db.cpp` migration depends on it. |
| BLAKE2b (channel passwords) | libsodium `crypto_generichash` | **`Blake2Fast`** (MIT) or BouncyCastle `Blake2bDigest` | MIT | Salted BLAKE2b-256, must produce identical digests to keep existing channel passwords working. Blake2Fast is SIMD and fast enough for the net thread, preserving the reason BLAKE2b was chosen over Argon2 here. |
| Ed25519 identity | libsodium | **BouncyCastle `Ed25519Signer`** | MIT | ⚠️ **Not in .NET 10.** [dotnet/runtime#63174](https://github.com/dotnet/runtime/issues/63174) is api-approved but milestoned **11.0.0**. Since Option A already pulls in BouncyCastle, this is free. |
| Self-signed cert gen | mbedTLS x509write | **`CertificateRequest.CreateSelfSigned`** | built-in | Much nicer than the C++ version. ECDSA-P256, same as today. Add the Ed25519 SAN (§3.4). |
| CSPRNG | libsodium | **`RandomNumberGenerator`** | built-in | — |
| Opus codec | libopus 1.6 | **P/Invoke libopus 1.6** | BSD | **Keep native.** [Concentus](https://github.com/lostromb/concentus) is a pure-C# Opus port but it tracks **Opus 1.1** — it has no DRED, no 1.6 features. `docs/voice.md` §4 and `test_dred_toggle.cpp` depend on DRED. Concentus is a viable *fallback* for a future platform where native linking is impossible, not the primary. |
| Noise suppression | RNNoise (vendored) | **P/Invoke RNNoise** | BSD-3 + CC0 | **Keep native.** No managed port exists. It's ~5 exported functions; the binding is trivial. Already vendored at `third_party/rnnoise/`. |
| Audio capture/playback | miniaudio | **P/Invoke miniaudio via a shim** | MIT-0/PD | **Keep native**, see §5.2. Managed alternatives exist ([SoundFlow](https://www.nuget.org/packages/SoundFlow), [MiniaudioSharp](https://www.nuget.org/packages/MiniaudioSharp), NAudio/CSCore for Windows-only) but auto-generated bindings marshal the callback into managed code, which is exactly what you must avoid (§5.1). Write the shim yourself. |
| Energy VAD | hand-rolled | port verbatim | — | ~60 lines. Trivial. |
| Protobuf | protobuf-lite (C++) | **`Google.Protobuf`** + `Grpc.Tools` | BSD | ⚠️ Reference `Grpc.Tools` for the `protoc` MSBuild integration even though there is no gRPC here — it is the standard way to codegen `.proto` in a `.csproj`. `<Protobuf Include="../../proto/voicecat.proto" GrpcServices="None" />`. The `.proto` needs **zero changes**. |
| SQLite | sqlite3 | **`Microsoft.Data.Sqlite`** | MIT | Bundles SQLitePCLRaw; works with NativeAOT. Same schema, same file — an existing `voicecat.db` opens unchanged. |
| Logging | spdlog | **`Microsoft.Extensions.Logging`** (+ Serilog console sink) | MIT/Apache | Use `LoggerMessage` source generators on any path near the hot loop. Never log from an audio path. |
| Server config | `server.toml` | **`Tomlyn`** (MIT) or switch to JSON + `System.Text.Json` | MIT | Tomlyn keeps `server.toml` compatible; recommended, since operator-facing config shouldn't churn. |
| CLI arg parsing | hand-rolled | **`System.CommandLine`** | MIT | For `VoiceCat.Cli` and the server. |
| Build | CMake + vcpkg | **`dotnet build`** + a small native build script | — | vcpkg disappears entirely except for the 3 native libs, which you can vendor as sources and build with a 20-line CMakeLists or even `cl`/`gcc` directly. |
### 4.1 License check
Every replacement is MIT / BSD / Apache-2.0 / built-in. **The hard no-GPL/LGPL rule in
`docs/tech-stack.md` §5 holds.** BouncyCastle is MIT. Konscious is MIT. Blake2Fast is MIT.
Tomlyn is MIT. The .NET runtime itself is MIT.
---
## 5. Real-time audio — the hard part
This is where a naive port fails. `docs/architecture.md` §3 states the rule: *"Audio
(real-time) threads must not allocate, lock, log, or do syscalls."* A managed runtime adds a
second rule: **they must not be subject to GC pauses, and they must not be managed threads at
all if avoidable.**
### 5.1 Why you cannot just P/Invoke miniaudio and use `[UnmanagedCallersOnly]`
miniaudio calls your `ma_device_data_proc` on an OS-owned real-time audio thread (WASAPI's
MMCSS thread, a CoreAudio IOThread, an ALSA thread). If that callback is a managed method:
1. **The thread must attach to the runtime.** First entry does thread registration; every
entry does a managed↔native transition.
2. **The thread becomes GC-suspendable.** A gen-0 collection anywhere in the process can
suspend it mid-callback. WASAPI in exclusive/low-latency mode will glitch on a 2 ms stall;
a gen-2 blocking collection is fatal to the audio.
3. **Delegate lifetime.** Even with `[UnmanagedCallersOnly]` (which correctly avoids the
marshalling stub and the `GCHandle` dance) the *reachability* problem is solved but the
suspension problem is not.
The existing Windows client sidesteps all of this by keeping the entire audio pipeline in
C++. A pure-.NET port has to solve it deliberately.
### 5.2 Recommended design: a native shim that owns the RT thread
Write **one small C file** (`native/miniaudio/voicecat_audio_shim.c`, est. 300400 lines)
that compiles miniaudio and exposes a *pull/push ring-buffer API* instead of a callback API:
```c
// The audio callback lives entirely in C. It only ever touches lock-free ring buffers.
// Managed code polls. No managed frame is ever on an RT stack.
vcsh_device* vcsh_capture_open (const char* device_id, uint32_t channels, uint32_t rate);
size_t vcsh_capture_read (vcsh_device*, int16_t* dst, size_t frames); // non-blocking
vcsh_device* vcsh_playback_open(const char* device_id, uint32_t channels, uint32_t rate);
size_t vcsh_playback_write(vcsh_device*, const int16_t* src, size_t frames);
int vcsh_playback_wait(vcsh_device*, int timeout_ms); // eventfd/Event, wakes the mixer
void vcsh_enumerate(int capture, vcsh_device_info** out, size_t* n);
```
Managed side then runs a **normal, dedicated, non-RT `Thread`** at
`ThreadPriority.Highest`, woken by `vcsh_playback_wait`, that does: drain jitter buffers →
Opus decode → NR → gain/mute → mix → `vcsh_playback_write`. The ring absorbs GC pauses; size
it for ~120 ms (6 × 20 ms frames), which is well within the latency budget the jitter buffer
already targets (`target_depth_ms_` starts at 40).
This is *the same architecture the code already has*`AudioEngine` already runs a
`mixer_timer_thread_` for the iOS external-playback path and already has per-stream ring
buffers (`RemoteStream::ring`). You are generalising the iOS path to every platform. That is
a pleasing simplification, and it means the iOS design needs no special case at all.
**Bonus:** it makes `vc_set_external_playback` / `vc_set_mixed_output_sink` disappear as
special modes. Everything is external playback; the shim is just one more sink.
### 5.3 GC and allocation discipline in the managed audio path
Even off the RT thread, the decode/mix loop runs 50×/second per stream and must not churn:
- `<ServerGarbageCollector>false</ServerGarbageCollector>` and
`<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>` on client apps.
Set `GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency` while a call is active.
- **Preallocate everything at stream init**, exactly as `RemoteStream::init_ring` does today.
Use `int16[]` fields, not `new` per frame.
- Use `Span<short>` / `ReadOnlySpan<short>` throughout the DSP; `ArrayPool<short>.Shared` for
the rare variable-size case. Never LINQ, never `IEnumerable`, never `List<T>` growth on
this path.
- Marshal to native with `fixed` + raw pointers, or declare P/Invokes as
`[LibraryImport]` taking `ref short` / `ReadOnlySpan<short>` — the source generator emits
pinning without a marshalling stub. **Do not** use `Marshal.Copy` per frame.
- **Add an allocation regression test.** `GC.GetAllocatedBytesForCurrentThread()` before/after
1000 simulated mix cycles must be ~0. This is a cheap, high-value test the C++ code can't
even express.
- The mixer's soft limiter, the RMS level meter, and the RNNoise call are all
fixed-work-per-frame — they port directly.
### 5.4 Jitter buffer
`JitterBuffer` uses `std::map<uint32_t, Frame>` keyed by timestamp with wraparound handling,
plus `try_lock` everywhere so the RT thread never blocks. In C#:
- `SortedDictionary<uint,Frame>` allocates per insert. Prefer a **fixed-capacity circular
array of pre-allocated frame slots** indexed by `(ts / frameSamples) % capacity` — the
buffer is bounded at 500 ms anyway (`kLateDropSamples`), so a ring is the natural shape and
removes all allocation. This is a genuine improvement over the current C++.
- Replace `try_lock` with `Monitor.TryEnter` or, better, make the ring single-producer
(net thread) / single-consumer (mixer thread) with `Volatile`/`Interlocked` indices and drop
the lock entirely.
- Keep the EWMA jitter estimation, the leading-edge reseed, and the frame-skip catch-up logic
**verbatim** — that logic is subtle, hard-won, and covered by `test_jitter_depth.cpp`.
### 5.5 Opus P/Invoke
```csharp
[LibraryImport("opus")]
internal static partial int opus_encode(IntPtr st, ReadOnlySpan<short> pcm, int frameSize,
Span<byte> data, int maxDataBytes);
```
- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. Declare one
overload per argument shape (`int`, `out int`) with `EntryPoint = "opus_encoder_ctl"`. This
works on all the ABIs we target (x64 SysV, x64 Win, arm64 AAPCS) because all the CTLs we use
take a single `int`/`int*`. **Note this explicitly in code comments** — it's a real
portability caveat if a future CTL takes a different shape.
- DRED (`opus_dred_alloc`, `opus_dred_parse`, `opus_decoder_dred_decode`) binds the same way.
Guard with a runtime feature check as `opus_codec.cpp` does today.
- **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link
`libopus.a` via `<NativeReference>` in the `.csproj`. Multi-target the DllImport name with a
`const string` behind `#if IOS`.
---
## 6. Core client port — `VoiceCat.Core`
`vc_client` (107 KB) is the biggest single unit. It becomes `VoiceCatClient : IAsyncDisposable`.
### 6.1 The C ABI goes away — and the API gets much better
The 60-odd `vc_*` functions were shaped by C ABI constraints. In C#:
| C ABI pattern | C# replacement |
|---------------|----------------|
| `vc_result` enum returns | Exceptions for programmer errors; `VoiceCatResult` for protocol outcomes |
| `vc_callbacks.on_event` + `vc_event` union-ish struct | **`IAsyncEnumerable<VoiceCatEvent>`** or typed `event` handlers per event type. Kill the `u32a` generic-payload field — use a discriminated hierarchy (`record UserJoined(uint UserId, uint ChannelId, string Nick)`). |
| `VC_EVENT_JOIN_RESULT` correlating with `vc_join_channel` | **`Task<JoinResult> JoinChannelAsync(uint id, string? pw, CancellationToken ct)`** — request/response correlation via `TaskCompletionSource` keyed on `Envelope.request_id`. This removes an entire class of "which reply was mine" bugs and shrinks every client's code. |
| `vc_list_channels` + `vc_free_channel_list` | `IReadOnlyList<Channel> Channels { get; }` — no ownership contract at all |
| `vc_get_server_identity_display(buf, cap, out len)` two-call idiom | `string ServerIdentityDisplay { get; }` |
| `vc_set_pcm_sink` / `vc_set_mixed_output_sink` / `vc_stream_feed_pcm` | Keep as-is conceptually — they're the bot/extension API. `Action<PcmFrame>` or a `ChannelWriter<T>`. Document the no-blocking rule just as loudly. |
| `vc_test_inject_capture` | `internal` test hook, not public API |
**Do this deliberately, not accidentally.** Write `docs/api-dotnet.md` as the successor to
`voicecat.h`, and keep the same rule from `CLAUDE.md`: changing it is a versioned act.
### 6.2 Threading model in C#
| C++ | C# |
|-----|-----|
| Asio `io_context` on `io_thread_` | A single `async` read loop over `PipeReader` per connection; no explicit thread |
| `WorkerPool` (blocking work) | Default `ThreadPool``Task.Run` for Argon2id, SQLite, DNS |
| Event queue drained by UI | `System.Threading.Channels.Channel<VoiceCatEvent>` (already what the Windows client does) |
| Mixer timer thread | Dedicated `Thread` (§5.2) — **not** a `Task`, the thread pool is not for this |
| Capture/encode thread | Dedicated `Thread`, fed by the shim's capture ring |
`WorkerPool` (861 bytes) simply deletes.
### 6.3 State model
`client.cpp` holds channel/user/stream maps guarded by mutexes and exposes them through
pull-based `vc_list_*`. In C#, hold them as immutable snapshots swapped with
`Volatile.Write` — readers get a consistent view with no locking, and the UI can bind to it
directly. `VC_EVENT_CHANNEL_LIST` becomes "a new snapshot is available", which is what it
already means.
---
## 7. Server port — `VoiceCat.Server`
The most mechanical part of the project. Straight `async`/`await` network code.
| Component | Port notes |
|-----------|-----------|
| `server.cpp` — accept loop | `Socket.AcceptAsync` loop + `Task` per connection. Trivial. |
| `conn_session.cpp` (34 K) — per-conn protocol | The bulk. A big `switch` on `Envelope.BodyCase`. Mechanical; write it against the ported xUnit tests. |
| `session_registry.cpp` | `ConcurrentDictionary<ulong, Session>` + a channel-membership index. Simpler than the C++. |
| `media_relay.cpp` — the SFU | ⚠️ **The one hot path on the server.** Per inbound datagram: parse 20-byte header → look up ssrc → fan out unmodified to N subscribers. Must be allocation-free: `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)` into a pooled buffer, `SendToAsync` per subscriber. Do **not** decrypt — the design already forbids it, which is what keeps this cheap. Benchmark this specifically (§11.5). |
| `db.cpp` (26 K) — SQLite | `Microsoft.Data.Sqlite`, same schema, same file. Keep raw SQL — do not introduce EF Core; the schema is 4 tables and EF's startup cost hurts the "single binary, instant start" goal. |
| `identity.cpp` | `CertificateRequest` + BouncyCastle Ed25519. Reads the same on-disk files. |
| Keepalive reaper | `PeriodicTimer` — cleaner than the `asio::steady_timer`. |
| `server.toml` | Tomlyn, unchanged format. |
### 7.1 Deployment — keeping the "single static binary" promise
`docs/deployment.md` promises a single statically-linked executable with no runtime to
install. **NativeAOT preserves this:**
```xml
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
```
- ✅ SQLitePCLRaw, Google.Protobuf, and BouncyCastle are all AOT-compatible.
- ✅ Startup drops to ~5 ms; binary lands around 1525 MB (vs. the current C++ static binary
— comparable order of magnitude).
- ⚠️ **No reflection-based JSON/config.** Use `System.Text.Json` source generators
(`JsonSerializerContext`) if you use JSON anywhere. Tomlyn's model binding uses reflection —
either use its low-level `DocumentSyntax` API or add trim descriptors.
- ⚠️ Cross-compilation is per-RID; you need a build machine per target (`linux-x64`,
`linux-arm64`, `win-x64`, `osx-arm64`). Same as today with vcpkg, so no regression.
- The Docker image gets *simpler*: `FROM scratch`-ish with a NativeAOT binary, or
`mcr.microsoft.com/dotnet/runtime-deps:10.0-noble`.
**Alternative if AOT fights you:** self-contained single-file publish
(`PublishSingleFile` + `SelfContained`) — bigger (~70 MB) and slower to start, but no AOT
constraints. Keep as a fallback per-RID, not the default.
---
## 8. Clients
### 8.1 Windows — the easy one
The `net10.0-windows` WinForms app is already C# and already structured around
`Channel<VoiceCatEvent>` + a 30 ms UI-thread pump. The port is:
1. Delete `VoiceCat.Interop` (the P/Invoke layer) and `VoiceCat.Interop.Tests`.
2. `<ProjectReference Include="VoiceCat.Core" />`.
3. Update ~40 call sites from `VcResult r = Native.vc_join_channel(...)` to
`await client.JoinChannelAsync(...)`. Mostly a find/replace plus `async void`
`async Task` hygiene on event handlers.
4. `Audio/ProcessLoopbackCapture.cs`, `ProcessAudioMixer.cs`, `AudioSessionEnumerator.cs`
(WASAPI process loopback, `AUDIOCLIENT_ACTIVATION_PARAMS`) — **unchanged**. They already
feed `vc_stream_feed_pcm`; they'll feed `client.FeedPcm(...)`.
5. `Native/RawInput.cs` (PTT hotkeys), `Models/PasswordProtector.cs` (DPAPI),
`Notifications/*` (SAPI announcer, sound pool) — **unchanged**.
**WinForms accessibility (the reason it was chosen over WinUI 3) is unaffected.** Keep it.
**Estimated effort: 12 weeks.** This client is nearly free.
### 8.2 macOS — AppKit in C#
`net10.0-macos` gives full AppKit bindings via [dotnet/macios](https://github.com/dotnet/macios).
The Swift AppKit code maps almost line-for-line:
| Swift | C# |
|-------|-----|
| `NSWindowController`, `NSOutlineView`, `NSTableViewDataSource` | Same types, same selectors, PascalCase |
| `@objc func handleClick(_ sender: Any)` | `[Export("handleClick:")] void HandleClick(NSObject sender)` |
| `accessibilityLabel`, `NSAccessibility.post(.announcement)` | `AccessibilityLabel`, `NSAccessibility.PostNotification(...)`**full VoiceOver parity, the reason AppKit was chosen holds** |
| `ScreenAudioCapture.swift` — ScreenCaptureKit | ✅ **ScreenCaptureKit is bound** in `net10.0-macos` (`SCStream`, `SCContentFilter`, `SCStreamConfiguration` incl. `CapturesAudio` / `ExcludesCurrentProcessAudio`). Per-app include/exclude filters and the VoiceOver-exclusion set port directly. |
| `InputDeviceCapture.swift` — CoreAudio | AVFoundation/CoreAudio bound; or just use the miniaudio shim on macOS |
`MainWindowController.swift` is 70 KB — this is the single largest UI rewrite. Budget for it.
⚠️ **Distribution:** a `net10.0-macos` app bundle needs codesigning + notarization, and
NativeAOT for macOS app bundles is supported but adds a step. Nothing blocking; just not
free.
### 8.3 iOS — the SwiftUI gap
This is the only client with no mechanical path, because **SwiftUI has no C# equivalent.**
Three options:
| Option | Pros | Cons |
|--------|------|------|
| **A. UIKit in C#** (`net10.0-ios`, hand-written) | Full API access, best accessibility control, matches the macOS/AppKit approach, no extra framework | The ~150 KB of SwiftUI views (`SettingsView`, `ChannelTreeView`, `ChatView`, …) must be re-authored as UIKit — a real rewrite, not a translation |
| **B. .NET MAUI** | Fastest to write; XAML declarative style is closest in spirit to SwiftUI; one codebase could later cover macOS too (Mac Catalyst) | ⚠️ Accessibility is weaker than native UIKit — and the project *explicitly* chose native toolkits for screen-reader quality (`roadmap.md` §2). Extra abstraction layer over the audio-sensitive app lifecycle. |
| **C. Avalonia** | One UI codebase for Windows + macOS + iOS | ⚠️ Same accessibility objection as MAUI, *and* it would mean abandoning WinForms/AppKit — contradicts two settled decisions |
**Recommendation: Option A (UIKit).** It's more work but it is the only choice consistent
with the accessibility commitments already made twice in the docs. Budget it as the largest
single client task.
What ports cleanly regardless:
- `IOSAudioRouter.swift` (31 KB) — `AVAudioSession` is fully bound. `SetPreferredDataSource`,
`SetPreferredPolarPattern`, `AllowBluetoothA2DP`, `MeasurementMode` all exist in C#. The
re-entrancy guards and route-change filtering (`.categoryChange` / `.routeConfigurationChange`
/ `.override`) port verbatim. **Keep the invariants in `voice.md` §8 exactly.**
- `IOSVoiceProcessingEngine.swift` (24 KB) — `AVAudioEngine`, `AVAudioSourceNode`,
`SetVoiceProcessingEnabled(true)`, `VoiceProcessingAgcEnabled` are all bound.
- Under §5.2's design, iOS stops being a special case: the core is *always* externally
driven, and `AVAudioEngine` is simply the iOS "shim" implementation.
⚠️ **iOS + NativeAOT:** .NET for iOS ships Mono AOT by default; NativeAOT for iOS is
[still experimental](https://learn.microsoft.com/en-us/dotnet/maui/deployment/nativeaot).
Mono AOT is fine for the host app (it's what every Xamarin/MAUI app ships). Do not depend on
NativeAOT on iOS.
⚠️ **App Store:** you already ship `me.iamtalon.voicecat`. A runtime change is invisible to
review, but re-validate background-audio behaviour (`UIBackgroundModes: audio`) under Mono —
managed finalizers and the GC must not stall the audio render callback while backgrounded.
§5.2's native-ring design is what protects you here.
### 8.4 iOS screen sharing — **keep this in Swift**
You anticipated this correctly. The ReplayKit Broadcast Upload Extension should **not** be
ported.
**Why:**
1. **The 50 MB jetsam cap.** A managed runtime (Mono AOT + metadata + GC heap) inside a
separate appex process eats a meaningful fraction of that before your code runs. The
current Swift `SampleHandler` is 4.4 KB and does one `AVAudioConverter` call per buffer.
2. **.NET for iOS does not officially support broadcast upload extensions.** Microsoft's own
guidance is that this is a [known gap with no documentation](https://learn.microsoft.com/en-sg/answers/questions/2006706/issues-with-bundling-ios-broadcast-extension-in-ne);
the supported extension types are enumerated and this isn't reliably among them.
3. **There is nothing to gain.** The extension deliberately does *not* link the core
(`voice.md` §9) — it converts PCM and writes to a shared ring. It is already a
language-agnostic boundary.
**The boundary is already clean.** `BroadcastAudioRing.swift` is an mmap'd file in an App
Group with an SPSC ring layout. C# reads it with `MemoryMappedFile.CreateFromFile` +
`MemoryMappedViewAccessor`, and `CFNotificationCenter` (Darwin notifications) is bound in
`net10.0-ios`. **Action: freeze `BroadcastAudioRing`'s binary layout as a documented struct**
(magic, version, capacity, head, tail, activeFlag, sample format) in
`docs/broadcast-ring-format.md`, so the Swift writer and the C# reader are contractually
pinned. The C# `BroadcastAudioPump` is then ~150 lines.
This leaves the repo with exactly **one Swift file plus one shared Swift ring** — an
acceptable, well-justified exception to "pure C#", and dramatically less than the current
three Swift codebases.
### 8.5 What about a Linux client?
Not in scope today, but worth noting: once the core is `net10.0` with a miniaudio shim
(which has ALSA/PulseAudio backends), a Linux client becomes a UI-only problem for the first
time. Avalonia would be the natural choice *there specifically*, without disturbing the
Windows/macOS/iOS decisions. Mention it in `roadmap.md`; don't build it now.
---
## 9. Tests
The 29 ctest cases **are** the specification. Port every one to xUnit in
`tests/VoiceCat.Tests/`. Grouping:
| Group | Tests | Notes |
|-------|-------|-------|
| Wire format | `test_envelope`, `test_voice_frame`, `test_frame_codec`, `test_frame_ms_reframe` | Port first. These are pure functions — fastest possible feedback on the protobuf + framing layers. |
| Crypto | `test_media_aead`, `test_tls_loopback`, `test_tofu_flow` | The AEAD test must produce **byte-identical** ciphertext to the C++ for a fixed key+nonce+AAD. Add that as a golden-vector test — it's your proof the port is wire-compatible. |
| Codec/DSP | `test_opus_codec`, `test_dred_toggle`, `test_noise_suppression`, `test_recv_noise_reduction`, `test_plc_cap` | Depend on the native P/Invokes; run them as soon as those exist. |
| Audio engine | `test_jitter_depth`, `test_channel_samplerate`, `test_external_pcm`, `test_external_playback`, `test_vad_ptt_devices` | The subtle ones. `test_jitter_depth` guards the bounded-depth invariant — do not weaken it. |
| Integration | `test_m1_integration`, `test_m2_voice`, `test_m3_multistream`, `test_tcp_loopback`, `test_disconnect_left`, `test_reaper_timeout` | Real client + real server in-process. |
| Moderation/admin | `test_m5_permissions`, `test_m5_kick_ban_move_mute`, `test_m5_channel_crud`, `test_m5_admin_accounts` | Server-side; port with `VoiceCat.Server`. |
| ABI surface | `test_voice_client_abi`, `test_channel_user_list_abi` | These test the C ABI specifically — **rewrite as API-shape tests** against the new C# surface, don't port literally. |
**New tests the port should add:**
- Allocation regression on the mix loop (§5.3).
- AEAD golden vectors vs. C++ output.
- A **cross-implementation test**: C# client ↔ C++ server, and C++ `vccli` ↔ C# server, run
in CI for as long as both trees exist (§11).
`ctest --preset dev``dotnet test`. The house rule in `CLAUDE.md` ("every commit builds and
passes") carries over unchanged.
---
## 10. Documentation changes
Per the `CLAUDE.md` rule that docs and code stay in sync:
| Doc | Change |
|-----|--------|
| `docs/architecture.md` | Rewrite §1 (shared-core model — it's now a shared *assembly*), §4 (C ABI → C# API), §3 (threading — the shim design). Keep §5 (server) and §2 (layers) nearly as-is. |
| `docs/tech-stack.md` | Replace the whole dependency table. Re-run the license audit (§5) — the no-GPL rule still passes. |
| `docs/security.md` | ⚠️ **§2 needs rewriting** to describe BouncyCastle's exporter rather than mbedTLS's, and §1.1 should be updated when the Ed25519↔cert binding lands (§3.4). §3§6 unchanged. |
| `docs/voice.md` | §5 (jitter), §8 (pipeline), §10 (NR) get implementation-detail updates. The *protocol* sections (§2 frame format, §3 config, §4 loss resilience) are **unchanged** — that's the point. |
| `docs/protocol.md` | Unchanged unless you take Option B (§3.3), which adds two `AuthResult` fields and bumps to v3. |
| `docs/building.md` | Full rewrite: `dotnet build` + the native build script replace the CMake preset matrix. **Delete the "run ctest in PowerShell not Git Bash" warning** — that MinGW pathology disappears with the toolchain. |
| `docs/deployment.md` | §1.B/C update for NativeAOT publish; Docker base image changes. The zero-config promises hold. |
| `docs/roadmap.md` | Add the port as its own milestone; note the Linux-client possibility (§8.5). |
| `CLAUDE.md` | New build commands, new subsystem map, new house rules (no allocation in the audio path becomes explicit). |
| **new** `docs/api-dotnet.md` | The successor to `voicecat.h` — the versioned client API contract. |
| **new** `docs/broadcast-ring-format.md` | The frozen Swift↔C# App Group ring layout (§8.4). |
---
## 11. Migration strategy — the actual step-by-step
The guiding principle: **the C++ tree stays working and becomes the conformance oracle.** Do
not delete anything until the C# equivalent passes the same test against it. This is only
possible because Option A (§3.2) preserves wire compatibility — which is the main reason to
choose it.
Work on a long-lived branch (`cs-port` already exists). Each phase ends with a green build,
green tests, and an updated `PROGRESS.md` entry.
---
### Phase 0 — Foundations (est. 1 week)
1. Create the solution skeleton from §2. `Directory.Build.props` with
`net10.0`, `<Nullable>enable</Nullable>`, `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`,
`<AnalysisLevel>latest-all</AnalysisLevel>`, `<InvariantGlobalization>true</InvariantGlobalization>`.
2. `native/build-native.{ps1,sh}`: build libopus 1.6, RNNoise, and the (empty for now)
miniaudio shim into `runtimes/{rid}/native/`. Vendor the sources — drop vcpkg.
3. `VoiceCat.Protocol`: add `Google.Protobuf` + `Grpc.Tools`, point at the **existing**
`core/proto/voicecat.proto`, verify generated C# types compile.
4. CI: GitHub Actions matrix building both trees (C++ and C#) side by side.
**Exit criterion:** `dotnet build` produces empty-but-real assemblies; generated protobuf
types are present; native libs land in the right RID folders.
---
### Phase 1 — Wire format, provably compatible (est. 1 week)
1. Port `envelope.cpp` (frame `[u32 len][payload]`) using `System.IO.Pipelines`.
2. Port `voice_frame.h` — the 20-byte big-endian header. Use
`BinaryPrimitives.WriteUInt64BigEndian` etc.
3. Port `SodiumMediaCrypto``MediaCrypto` on `System.Security.Cryptography.ChaCha20Poly1305`,
including the nonce scheme and the sliding replay window. **Preserve the RFC 3711 §3.3
ordering.**
4. Port `test_envelope`, `test_voice_frame`, `test_frame_codec`, `test_media_aead`.
5. **Generate golden vectors from the C++ build** (a throwaway C++ main dumping sealed frames
for fixed inputs) and assert the C# produces identical bytes.
**Exit criterion:** golden-vector tests green. From here on, byte-compatibility is measured,
not assumed.
---
### Phase 2 — TLS + the exporter (est. 1.5 weeks — highest-risk phase, do it early)
1. Spike first, in isolation: a BouncyCastle `TlsClientProtocol``TlsServerProtocol`
loopback over a `Socket` pair, both calling
`ExportKeyingMaterial("voicecat media v1", ...)` and asserting the two sides agree.
2. **Then the real proof:** a C# BouncyCastle client handshaking against the **existing C++
mbedTLS server**, both exporting with the same label, and asserting the derived media keys
are identical. *This is the single most important checkpoint in the whole project.* If it
fails, stop and reconsider Option B before writing anything else.
3. Port `ServerCert`/`ServerIdentity` (`CertificateRequest` + BC Ed25519), the TOFU pin store
(`tofu_store.cpp` — a trivial text file), and cert-fingerprint pinning.
4. Port `test_tls_loopback`, `test_tofu_flow`, `test_tcp_loopback`.
**Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives
matching media keys, and pins the leaf fingerprint.
---
### Phase 3 — Codec + DSP (est. 1 week)
1. `VoiceCat.Codec`: libopus `[LibraryImport]`, `OpusEncoder`/`OpusDecoder`, the varargs-CTL
workaround (§5.5), DRED.
2. `VoiceCat.Dsp`: RNNoise binding, `EnergyVadProcessor`.
3. Port `test_opus_codec`, `test_dred_toggle`, `test_noise_suppression`.
**Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery
test green; RNNoise output matches the C++ within tolerance.
---
### Phase 4 — Server (est. 34 weeks)
Do the server before the client: it lets you point the **existing, trusted C++ `vccli`** at
it, which is a far better test client than a half-built C# one.
1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry.
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
question here** (§4).
3. `MediaRelay` — the allocation-free SFU fan-out.
4. Keepalive reaper, moderation/admin handlers.
5. Port `test_m1_integration`, `test_m5_*`, `test_disconnect_left`, `test_reaper_timeout`.
**Exit criterion:** ▶ **C++ `vccli` connects to the C# server, authenticates, joins a
channel, sends text, and exchanges voice with a second C++ `vccli`.** That is the M1+M2 exit
criterion from `roadmap.md`, re-proven against the new server.
---
### Phase 5 — Audio engine (est. 45 weeks — the hardest phase)
1. Write and validate `voicecat_audio_shim.c` standalone (a C test that loops mic→speaker
through the rings, no .NET involved).
2. `VoiceCat.Audio`: device enumeration, the allocation-free jitter buffer (§5.4), per-ssrc
decode with the **DRED → FEC → PLC** ladder, per-stream NR/gain/mute, the mixer, level
meters, talk-state edge detection.
3. The dedicated mixer thread + capture thread.
4. External feed/tap/mixed-sink — now the *normal* path, not special modes.
5. Port `test_jitter_depth`, `test_external_pcm`, `test_external_playback`,
`test_channel_samplerate`, `test_plc_cap`, `test_recv_noise_reduction`,
`test_frame_ms_reframe`.
6. **Add the allocation-regression test.**
**Exit criterion:** `test_jitter_depth`'s bounded-depth invariant holds; zero allocations per
mix cycle; a manual listen test on Windows and macOS with no audible glitching over 10
minutes.
---
### Phase 6 — Client core (est. 34 weeks)
1. `VoiceCatClient`: connect/TOFU/auth state machine, request/response correlation via
`TaskCompletionSource`, channel/user/stream snapshots, stream lifecycle, reframing, the
send path, the event stream.
2. `VoiceCat.Cli` — the `vccli` replacement, plus `voicecat-admin`.
3. Port `test_m2_voice`, `test_m3_multistream`, `test_vad_ptt_devices`; rewrite the two ABI
tests as API-shape tests.
**Exit criterion:** ▶ **Two C# `vccli` instances hold a multi-channel voice + text
conversation through the C# server**, and a C# `vccli` interoperates with a C++ `vccli` on
the same server. This is the full M0M3 criterion re-proven end to end.
---
### Phase 7 — Windows client (est. 12 weeks)
Per §8.1. Ship this first of the three GUIs — it's the cheapest and it validates the C# API
shape against a real, complete UI before you commit to two rewrites.
**Exit criterion:** feature parity with the current WinForms build, NVDA smoke-tested.
---
### Phase 8 — macOS client (est. 45 weeks)
Per §8.2. AppKit port, ScreenCaptureKit per-app audio selection, VoiceOver parity.
**Exit criterion:** feature parity with `VoiceCatMac`, VoiceOver smoke-tested, notarized
build produced.
---
### Phase 9 — iOS client (est. 57 weeks)
Per §8.3/§8.4. UIKit rewrite, `AVAudioSession` router port, `AVAudioEngine` VPIO path, and
the C# `BroadcastAudioPump` reading the **unchanged Swift** extension's ring.
**Exit criterion:** feature parity with `VoiceCatiOS`; screen-audio sharing works with the
Swift extension untouched; A2DP/stereo/VPIO preset matrix re-verified (this is where the
known stereo-A2DP class of bug lives — re-test it explicitly).
---
### Phase 10 — Cutover (est. 12 weeks)
1. Run both trees in CI for one full release cycle.
2. Delete `core/`, `server/`, `tools/`, `clients/apple/` (except `VoiceCatBroadcast/` and
`Shared/BroadcastAudioRing.swift`), `vcpkg/`, `CMakePresets.json`, root `CMakeLists.txt`.
3. Update every doc per §10.
4. Tag the last C++ commit so the oracle stays reachable.
---
### 11.5 Total estimate
**~79 months of focused single-developer work**, front-loaded with risk (Phase 2) and
back-loaded with volume (Phases 89). The server + core (Phases 06) is roughly 4 months and
is the part that removes the most complexity; the three GUIs are roughly half the calendar
time and almost none of the difficulty.
---
## 12. Risk register
| # | Risk | Severity | Mitigation |
|---|------|----------|------------|
| 1 | **BouncyCastle's exporter doesn't interoperate with mbedTLS's** | 🔴 Critical | Prove it in Phase 2 step 2, before any other work depends on it. Both implement RFC 8446 §7.5, so it should — but *verify*, don't assume. Fallback: Option B (§3.3). |
| 2 | **GC pauses cause audio glitches** | 🔴 High | Native shim owns the RT thread (§5.2); ~120 ms ring; allocation-regression test; `SustainedLowLatency`. This is the design's whole answer. |
| 3 | **iOS audio regressions under Mono AOT** | 🟠 Medium-High | The iOS audio path is already the most delicate part of the product (see the A2DP/VPIO invariants in `voice.md` §8). Re-test the full preset × route matrix. Do not port the invariants "roughly". |
| 4 | **Argon2id hashes don't verify → existing accounts locked out** | 🟠 Medium | Decide in Phase 4. Preferred: parse libsodium's PHC string and pass m/t/p to Konscious; verify against real hashes from an existing `voicecat.db` *before* writing the rest of `Db`. |
| 5 | **SFU relay throughput regression** | 🟠 Medium | Benchmark early (Phase 4): N=50 subscribers × 50 pps. Allocation-free `SocketAddress` receive + pooled buffers. .NET's socket layer is good; this should be fine, but measure. |
| 6 | **`ChaCha20Poly1305.IsSupported == false`** on some target | 🟡 Low | Startup check + BouncyCastle fallback. One-line risk. |
| 7 | **`opus_encoder_ctl` varargs breaks on a future ABI** | 🟡 Low | Only single-`int` CTLs are used; document it, add a test that exercises every CTL used. |
| 8 | **NativeAOT trimming breaks protobuf/SQLite reflection** | 🟡 Low | All three are AOT-tested upstream. Add an AOT-published smoke test to CI from Phase 4. |
| 9 | **macOS/iOS bindings lag a new Xcode** | 🟡 Low | dotnet/macios tracks Xcode closely (bindings exist through Xcode 26). Pin the workload version. |
| 10 | **Scope creep — "while we're rewriting, let's also…"** | 🟠 Medium | The port is a *translation*. The API-shape improvements in §6.1 are the only sanctioned redesign. Everything else goes in `roadmap.md`. |
---
## 13. What you gain
Worth being explicit, since this is 7+ months:
- **One language, one toolchain, one debugger.** No more CMake presets, vcpkg triplets,
MinGW-vs-PowerShell execution pathologies, XCFramework fat-static-lib packaging, or a C ABI
that has to be hand-mirrored into both Swift and C#.
- **Three UI codebases instead of three UI codebases *plus* a core plus two binding layers.**
The `VoiceCat.Interop` P/Invoke layer, the `VoiceCatCore` Swift wrapper, the module map, and
the whole `voicecat.h` ABI surface all cease to exist.
- **Better API.** `await client.JoinChannelAsync()` instead of "call this, then wait for
`VC_EVENT_JOIN_RESULT`, and hope it's yours."
- **Memory safety** across the entire protocol-parsing surface — the part most exposed to
hostile input.
- **8 native dependencies → 3**, each small and vendored.
- **Tests that can assert things C++ couldn't**, notably zero-allocation invariants.
And what you keep: the protocol, the wire format, the security model, the audio design, the
accessibility-first UI toolkit choices, and every single one of the 29 behavioural tests.