From 48c754aed9c038c67f7a30407f1322e915825f22 Mon Sep 17 00:00:00 2001 From: Talon Date: Wed, 16 Sep 2026 16:55:17 +0200 Subject: [PATCH] Add managed console client and interop scenarios --- PROGRESS.md | 11 ++ docs/porting-to-dotnet.md | 7 +- dotnet/README.md | 17 ++- dotnet/VoiceCat.slnx | 1 + dotnet/src/VoiceCat.Cli/Program.cs | 115 ++++++++++++++++++ dotnet/src/VoiceCat.Cli/VoiceCat.Cli.csproj | 12 ++ dotnet/src/VoiceCat.Cli/packages.lock.json | 51 ++++++++ .../tests/VoiceCat.Tests/ManagedCliTests.cs | 69 +++++++++++ .../VoiceCat.Tests/VoiceCat.Tests.csproj | 1 + 9 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 dotnet/src/VoiceCat.Cli/Program.cs create mode 100644 dotnet/src/VoiceCat.Cli/VoiceCat.Cli.csproj create mode 100644 dotnet/src/VoiceCat.Cli/packages.lock.json create mode 100644 dotnet/tests/VoiceCat.Tests/ManagedCliTests.cs diff --git a/PROGRESS.md b/PROGRESS.md index 2138eeb..90d2e52 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md index 148a5e5..c7e1629 100644 --- a/docs/porting-to-dotnet.md +++ b/docs/porting-to-dotnet.md @@ -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 M0–M3 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. --- diff --git a/dotnet/README.md b/dotnet/README.md index 942b6c3..fbb2ed5 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -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 diff --git a/dotnet/VoiceCat.slnx b/dotnet/VoiceCat.slnx index abd5ec4..62456af 100644 --- a/dotnet/VoiceCat.slnx +++ b/dotnet/VoiceCat.slnx @@ -7,6 +7,7 @@ + diff --git a/dotnet/src/VoiceCat.Cli/Program.cs b/dotnet/src/VoiceCat.Cli/Program.cs new file mode 100644 index 0000000..2911593 --- /dev/null +++ b/dotnet/src/VoiceCat.Cli/Program.cs @@ -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 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")))); + } + } +} diff --git a/dotnet/src/VoiceCat.Cli/VoiceCat.Cli.csproj b/dotnet/src/VoiceCat.Cli/VoiceCat.Cli.csproj new file mode 100644 index 0000000..82ea8ae --- /dev/null +++ b/dotnet/src/VoiceCat.Cli/VoiceCat.Cli.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + true + + + + + diff --git a/dotnet/src/VoiceCat.Cli/packages.lock.json b/dotnet/src/VoiceCat.Cli/packages.lock.json new file mode 100644 index 0000000..c313b73 --- /dev/null +++ b/dotnet/src/VoiceCat.Cli/packages.lock.json @@ -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, )" + } + } + } + } +} \ No newline at end of file diff --git a/dotnet/tests/VoiceCat.Tests/ManagedCliTests.cs b/dotnet/tests/VoiceCat.Tests/ManagedCliTests.cs new file mode 100644 index 0000000..1ec6231 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/ManagedCliTests.cs @@ -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 managedOut = managed.StandardOutput.ReadToEndAsync(), managedError = managed.StandardError.ReadToEndAsync(); + Task 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 aliceOut = alice.StandardOutput.ReadToEndAsync(); Task aliceError = alice.StandardError.ReadToEndAsync(); + Task bobOut = bob.StandardOutput.ReadToEndAsync(); Task 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."); + } +} diff --git a/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj b/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj index 1a0a91a..ef72bf1 100644 --- a/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj +++ b/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj @@ -6,6 +6,7 @@ +