Add managed console client and interop scenarios
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-16 16:55:17 +02:00
parent 82ad4c2811
commit 48c754aed9
9 changed files with 277 additions and 7 deletions
+11
View File
@@ -10,6 +10,17 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action
- **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.
- **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
+4 -3
View File
@@ -817,11 +817,12 @@ the required ten-minute Windows/macOS listen test is still a manual release gate
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.
**Checkpoint (2026-09-16):** `VoiceCat.Core` implements TOFU-gated TLS, concurrent correlated
**Complete (2026-09-16):** `VoiceCat.Core` implements TOFU-gated TLS, concurrent correlated
requests, snapshots/events, reconnects, encrypted UDP binding and send/receive stream
lifecycle. Two managed clients exchange text and decoded PCM through the managed server.
The remaining Phase 6 item is the managed console client and its explicit C++ CLI
interoperability scenario.
`VoiceCat.Cli` supports interactive channel text and deterministic headless text/voice runs.
Process tests prove two managed CLIs converse with decoded voice in both configured channels,
and a managed CLI exchanges text and bidirectional voice with the existing C++ CLI.
---
+13 -4
View File
@@ -1,9 +1,8 @@
# VoiceCat .NET rewrite
The first slice targets .NET 10: protobuf, control framing, voice headers, and media
encryption, TLS 1.3, persisted TOFU pins, server credentials, and an initial managed
control server. Media relay, client state, audio, and UI migration are next. The existing
C++ implementation remains the conformance oracle.
The .NET 10 implementation includes protocol, TLS/TOFU and media crypto, the control and
UDP server, client state, real-time audio, a console client and the Windows application.
The existing C++ implementation remains the conformance oracle while Apple clients move.
Codec/DSP wrappers now cover Opus, DRED recovery, RNNoise, and energy VAD. Build
the desktop native library before running their tests (CMake and a C compiler required):
@@ -44,6 +43,16 @@ dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
```
Run the managed console client interactively:
```powershell
dotnet run --project dotnet/src/VoiceCat.Cli -- --host 127.0.0.1 --port 8384 --nickname Alice --trust-first
```
Plain input sends channel text; `/join ID` changes channel and `/quit` exits. Headless
conformance options include `--voice`, `--send-text`, `--expect-text`, `--expect-voice`,
`--start-delay-ms` and `--timeout-seconds`; `--help` lists the complete syntax.
Dependencies are pinned in project files and lock files. Generated protobuf is build
output; the schema remains `core/proto/voicecat.proto`. Production dependencies are
Google.Protobuf (BSD-3-Clause), BouncyCastle.Cryptography (MIT), and the build-only
+1
View File
@@ -7,6 +7,7 @@
<Project Path="src/VoiceCat.Server/VoiceCat.Server.csproj" />
<Project Path="src/VoiceCat.Core/VoiceCat.Core.csproj" />
<Project Path="src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
<Project Path="src/VoiceCat.Cli/VoiceCat.Cli.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
+115
View File
@@ -0,0 +1,115 @@
using System.Text.Json;
using VoiceCat.Audio;
using VoiceCat.Core;
using Voicecat.V1;
return await CliCommand.RunAsync(args);
public static class CliCommand
{
public static async Task<int> RunAsync(string[] args)
{
try
{
var options = Options.Parse(args);
await using var client = new VoiceCatClient("VoiceCat.Cli", "0.1.0", options.Pins);
client.ConnectionStateChanged += state => Print("state", state.ToString());
long energy = 0;
client.Audio.MixedPcm += pcm => { long sum = 0; foreach (short sample in pcm) sum += Math.Abs((int)sample); Interlocked.Add(ref energy, sum); };
await client.ConnectAsync(options.Host, options.Port, (challenge, _) =>
{
Print("identity", challenge.CertificateFingerprint, new { status = challenge.Status.ToString() });
return ValueTask.FromResult(options.TrustFirst && challenge.Status == VoiceCat.Crypto.TofuStatus.FirstConnect);
});
AuthResult auth = await client.AuthenticateGuestAsync(options.Nickname);
if (!auth.Ok) throw new InvalidOperationException(auth.Error);
Print("authenticated", options.Nickname, new { userId = auth.Self.Id });
if (options.Channel != 1)
{
var joined = (await client.RequestAsync(new() { JoinChannel = new() { ChannelId = options.Channel } })).JoinChannelResult;
if (!joined.Ok) throw new InvalidOperationException(joined.Error);
}
using var stopped = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stopped.Cancel(); };
Task events = ObserveAsync(client, options.ExpectText, stopped.Token);
if (options.Voice)
{
VoiceSubscriptionResult subscribed = await client.SubscribeVoiceAsync();
if (!subscribed.Ok) throw new InvalidOperationException(subscribed.Error);
await client.StartStreamAsync(StreamKind.StreamMic, "CLI tone");
client.Audio.InputMode = AudioInputMode.AlwaysOn;
}
Print("ready", options.Nickname);
if (options.Delay > TimeSpan.Zero) await Task.Delay(options.Delay, stopped.Token);
if (options.SendText is not null)
client.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = options.Channel, Body = options.SendText, ClientMsgId = Guid.NewGuid().ToString("N") } });
Task? tone = options.Voice ? SendToneAsync(client, stopped.Token) : null;
if (options.OneShot)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(stopped.Token);
deadline.CancelAfter(options.Timeout);
while ((!string.IsNullOrEmpty(options.ExpectText) && !SeenText) || (options.ExpectVoice && Interlocked.Read(ref energy) < 100000))
await Task.Delay(20, deadline.Token);
Print("complete", options.Nickname, new { voiceEnergy = Interlocked.Read(ref energy) });
if (options.Linger > TimeSpan.Zero) await Task.Delay(options.Linger, deadline.Token);
stopped.Cancel();
}
else await InteractiveAsync(client, stopped.Token);
if (tone is not null) try { await tone; } catch (OperationCanceledException) { }
try { await events; } catch (OperationCanceledException) { }
return 0;
}
catch (OperationCanceledException) { Console.Error.WriteLine("VoiceCat.Cli timed out or was cancelled."); return 2; }
catch (Exception exception) { Console.Error.WriteLine(exception.Message); return 1; }
}
private static volatile bool SeenText;
private static async Task ObserveAsync(VoiceCatClient client, string? expected, CancellationToken token)
{
await foreach (Envelope message in client.ReadEventsAsync(token))
{
if (message.TextMessage is { } text)
{
Print("text", text.Body, new { senderId = text.SenderId, channelId = text.TargetId });
if (expected is null || text.Body == expected) SeenText = true;
}
if (message.UserEvent is { } user) Print("user", user.Kind.ToString(), new { userId = user.User?.Id ?? user.LeftId });
}
}
private static async Task SendToneAsync(VoiceCatClient client, CancellationToken token)
{
short[] pcm = new short[960];
for (int frame = 0; frame < 150 && !token.IsCancellationRequested; frame++)
{
for (int i = 0; i < pcm.Length; i++) pcm[i] = (short)(Math.Sin((frame * 960 + i) * Math.PI * 880 / 48000) * 8000);
foreach (StreamInfo stream in client.LocalStreams) client.Audio.FeedPcm(stream.StreamId, pcm, 1);
await Task.Delay(20, token);
}
}
private static async Task InteractiveAsync(VoiceCatClient client, CancellationToken token)
{
while (!token.IsCancellationRequested && await Console.In.ReadLineAsync(token) is { } line)
{
if (line == "/quit") return;
if (line.StartsWith("/join ") && uint.TryParse(line[6..], out uint channel)) await client.RequestAsync(new() { JoinChannel = new() { ChannelId = channel } }, token);
else client.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = client.Authentication?.Self.ChannelId ?? 1, Body = line, ClientMsgId = Guid.NewGuid().ToString("N") } });
}
}
private static void Print(string type, string value, object? extra = null) => Console.WriteLine(JsonSerializer.Serialize(new { type, value, extra }));
private sealed record Options(string Host, ushort Port, string Nickname, string Pins, uint Channel, bool TrustFirst, bool Voice, bool ExpectVoice, string? SendText, string? ExpectText, TimeSpan Delay, TimeSpan Linger, TimeSpan Timeout)
{
internal bool OneShot => SendText is not null || ExpectText is not null || ExpectVoice;
internal static Options Parse(string[] args)
{
string Value(string name, string fallback) { int i = Array.IndexOf(args, name); return i >= 0 && i + 1 < args.Length ? args[i + 1] : fallback; }
bool Has(string name) => args.Contains(name, StringComparer.OrdinalIgnoreCase);
if (Has("--help")) { Console.WriteLine("VoiceCat.Cli --host HOST --port PORT --nickname NAME [--trust-first] [--channel ID] [--voice] [--send-text TEXT] [--expect-text TEXT] [--expect-voice] [--start-delay-ms N]"); Environment.Exit(0); }
return new(Value("--host", "127.0.0.1"), ushort.Parse(Value("--port", "8384")), Value("--nickname", Environment.UserName),
Value("--pins", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VoiceCat", "cli-tofu.txt")), uint.Parse(Value("--channel", "1")), Has("--trust-first"), Has("--voice") || Has("--expect-voice"), Has("--expect-voice"),
Array.IndexOf(args, "--send-text") is int send and >= 0 && send + 1 < args.Length ? args[send + 1] : null,
Array.IndexOf(args, "--expect-text") is int expect and >= 0 && expect + 1 < args.Length ? args[expect + 1] : null,
TimeSpan.FromMilliseconds(int.Parse(Value("--start-delay-ms", "0"))), TimeSpan.FromMilliseconds(int.Parse(Value("--linger-ms", "1000"))),
TimeSpan.FromSeconds(int.Parse(Value("--timeout-seconds", "15"))));
}
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../VoiceCat.Core/VoiceCat.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,51 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}
@@ -0,0 +1,69 @@
using System.Diagnostics;
using static VoiceCat.Tests.ServerTests;
namespace VoiceCat.Tests;
public class ManagedCliTests
{
[Fact]
public async Task ManagedCliInteroperatesWithExistingCppCli()
{
string? nativeCli = Environment.GetEnvironmentVariable("VOICECAT_VCCLI");
if (string.IsNullOrEmpty(nativeCli)) return; // Full conformance runs set this explicitly.
await using var fixture = new ServerFixture();
string cli = Path.Combine(FindRoot(), "dotnet", "src", "VoiceCat.Cli", "bin", "Release", "net10.0", "VoiceCat.Cli.dll");
using Process managed = Start(cli, fixture, "Managed", "Managed checkpoint", "Native checkpoint", 2);
await Task.Delay(750); // Native vccli sends its one-shot text immediately after auth.
var nativeStart = new ProcessStartInfo(nativeCli) { WorkingDirectory = fixture.Directory, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true };
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Native",
"--channel", "2", "--text", "Native checkpoint", "--test-tone-ms", "5000" }) nativeStart.ArgumentList.Add(argument);
using Process native = Process.Start(nativeStart)!;
Task<string> managedOut = managed.StandardOutput.ReadToEndAsync(), managedError = managed.StandardError.ReadToEndAsync();
Task<string> nativeOut = native.StandardOutput.ReadToEndAsync(), nativeError = native.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35));
await Task.WhenAll(managed.WaitForExitAsync(timeout.Token), native.WaitForExitAsync(timeout.Token));
string mout = await managedOut, nout = await nativeOut;
Assert.True(managed.ExitCode == 0, await managedError + Environment.NewLine + mout);
Assert.True(native.ExitCode == 0, await nativeError + Environment.NewLine + nout);
Assert.Contains("Native checkpoint", mout); Assert.Contains("Managed checkpoint", nout);
Assert.Contains("[test-tone] received=", nout); Assert.DoesNotContain("\"voiceEnergy\":0", mout);
}
[Theory]
[InlineData(1u)]
[InlineData(2u)]
public async Task TwoManagedCliProcessesExchangeTextAndDecodedVoice(uint channel)
{
await using var fixture = new ServerFixture();
string root = FindRoot();
string cli = Path.Combine(root, "dotnet", "src", "VoiceCat.Cli", "bin", "Release", "net10.0", "VoiceCat.Cli.dll");
Assert.True(File.Exists(cli), $"Managed CLI was not built at {cli}.");
using Process alice = Start(cli, fixture, "Alice", "Alice says hello", "Bob says hello", channel);
using Process bob = Start(cli, fixture, "Bob", "Bob says hello", "Alice says hello", channel);
Task<string> aliceOut = alice.StandardOutput.ReadToEndAsync(); Task<string> aliceError = alice.StandardError.ReadToEndAsync();
Task<string> bobOut = bob.StandardOutput.ReadToEndAsync(); Task<string> bobError = bob.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35));
await Task.WhenAll(alice.WaitForExitAsync(timeout.Token), bob.WaitForExitAsync(timeout.Token));
string aout = await aliceOut, bout = await bobOut;
Assert.True(alice.ExitCode == 0, await aliceError + Environment.NewLine + aout);
Assert.True(bob.ExitCode == 0, await bobError + Environment.NewLine + bout);
Assert.Contains("Bob says hello", aout); Assert.Contains("Alice says hello", bout);
Assert.Contains("\"type\":\"complete\"", aout); Assert.Contains("\"type\":\"complete\"", bout);
Assert.DoesNotContain("\"voiceEnergy\":0", aout); Assert.DoesNotContain("\"voiceEnergy\":0", bout);
}
private static Process Start(string cli, ServerFixture fixture, string name, string send, string expect, uint channel)
{
var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
foreach (string argument in new[] { cli, "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nickname", name,
"--pins", Path.Combine(fixture.Directory, name + ".cli.pins"), "--trust-first", "--channel", channel.ToString(), "--voice", "--expect-voice", "--send-text", send,
"--expect-text", expect, "--start-delay-ms", "1500", "--timeout-seconds", "20" }) start.ArgumentList.Add(argument);
return Process.Start(start) ?? throw new InvalidOperationException("Could not start managed CLI.");
}
private static string FindRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "CMakePresets.json"))) directory = directory.Parent;
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
}
}
@@ -6,6 +6,7 @@
<ItemGroup>
<ProjectReference Include="../../../clients/windows/VoiceCat.Managed/VoiceCat.Managed.csproj" />
<ProjectReference Include="../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
<ProjectReference Include="../../src/VoiceCat.Cli/VoiceCat.Cli.csproj" ReferenceOutputAssembly="false" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" PrivateAssets="all" />