140 lines
9.0 KiB
C#
140 lines
9.0 KiB
C#
using System.Diagnostics;
|
|
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, 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);
|
|
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, 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 < 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)
|
|
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)
|
|
{
|
|
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, 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] [--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") || 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"))), toneDuration);
|
|
}
|
|
}
|
|
}
|