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")))); } } }