Add managed console client and interop scenarios
This commit is contained in:
+11
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+13
-4
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user