Files
voice-cat/dotnet/src/VoiceCat.Cli/Program.cs
T

116 lines
7.4 KiB
C#
Raw Normal View History

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