Complete managed macOS platform bring-up
.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 / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-18 18:51:53 +02:00
parent ab460ae12b
commit 310c0f09dd
21 changed files with 421 additions and 83 deletions
+34 -10
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.Json;
using VoiceCat.Audio;
using VoiceCat.Core;
@@ -43,8 +44,14 @@ public static class CliCommand
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)
Task? tone = options.Voice ? SendToneAsync(client, options.ToneDuration, stopped.Token) : null;
if (options.ToneDuration > TimeSpan.Zero)
{
await tone!;
Print("complete", options.Nickname, new { voiceEnergy = Interlocked.Read(ref energy) });
stopped.Cancel();
}
else if (options.OneShot)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(stopped.Token);
deadline.CancelAfter(options.Timeout);
@@ -75,15 +82,30 @@ public static class CliCommand
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)
private static async Task SendToneAsync(VoiceCatClient client, TimeSpan requestedDuration, CancellationToken token)
{
TimeSpan duration = requestedDuration > TimeSpan.Zero ? requestedDuration : TimeSpan.FromSeconds(3);
int frames = checked((int)Math.Ceiling(duration.TotalSeconds * 50));
const int leadFrames = 8;
long started = Stopwatch.GetTimestamp();
short[] pcm = new short[960];
for (int frame = 0; frame < 150 && !token.IsCancellationRequested; frame++)
for (int frame = 0; frame < frames && !token.IsCancellationRequested; frame++)
{
if (frame >= leadFrames)
{
long target = started + (frame - leadFrames) * Stopwatch.Frequency / 50;
while (true)
{
double remainingMilliseconds = (target - Stopwatch.GetTimestamp()) * 1000.0 / Stopwatch.Frequency;
if (remainingMilliseconds <= 1) break;
await Task.Delay(TimeSpan.FromMilliseconds(remainingMilliseconds - 0.5), token);
}
}
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);
foreach (StreamInfo stream in client.LocalStreams)
while (!client.Audio.FeedPcm(stream.StreamId, pcm, 1)) await Task.Delay(1, token);
}
await Task.Delay(TimeSpan.FromMilliseconds(leadFrames * 20), token);
}
private static async Task InteractiveAsync(VoiceCatClient client, CancellationToken token)
{
@@ -96,20 +118,22 @@ public static class CliCommand
}
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)
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, TimeSpan ToneDuration)
{
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); }
if (Has("--help")) { Console.WriteLine("VoiceCat.Cli --host HOST --port PORT --nickname NAME [--trust-first] [--channel ID] [--voice] [--test-tone-seconds N] [--send-text TEXT] [--expect-text TEXT] [--expect-voice] [--start-delay-ms N]"); Environment.Exit(0); }
TimeSpan toneDuration = TimeSpan.FromSeconds(int.Parse(Value("--test-tone-seconds", "0")));
if (toneDuration < TimeSpan.Zero || toneDuration > TimeSpan.FromHours(1)) throw new ArgumentOutOfRangeException("--test-tone-seconds", "Tone duration must be between 0 and 3600 seconds.");
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"),
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") || toneDuration > TimeSpan.Zero, 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"))));
TimeSpan.FromSeconds(int.Parse(Value("--timeout-seconds", "15"))), toneDuration);
}
}
}
@@ -5,6 +5,7 @@ internal static class PrivateFiles
public static void Write(string path, ReadOnlySpan<byte> data)
{
string destination = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
string temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
@@ -1,4 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Condition="'$(Protobuf_ProtocFullPath)' == '' and Exists('/opt/homebrew/bin/protoc')">
<!-- Grpc.Tools currently ships an x64-only macOS protoc. Prefer Homebrew's native
compiler on Apple Silicon hosts that do not have Rosetta installed. -->
<Protobuf_ProtocFullPath>/opt/homebrew/bin/protoc</Protobuf_ProtocFullPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.36.1" />
<PackageReference Include="Grpc.Tools" Version="2.83.0" PrivateAssets="all" />