2026-09-18 18:51:53 +02:00
using System.Diagnostics ;
2026-09-16 16:55:17 +02:00
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" ) } });
2026-09-18 18:51:53 +02:00
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 )
2026-09-16 16:55:17 +02:00
{
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 });
}
}
2026-09-18 18:51:53 +02:00
private static async Task SendToneAsync ( VoiceCatClient client , TimeSpan requestedDuration , CancellationToken token )
2026-09-16 16:55:17 +02:00
{
2026-09-18 18:51:53 +02:00
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 ();
2026-09-16 16:55:17 +02:00
short [] pcm = new short [ 960 ];
2026-09-18 18:51:53 +02:00
for ( int frame = 0 ; frame < frames && ! token . IsCancellationRequested ; frame ++)
2026-09-16 16:55:17 +02:00
{
2026-09-18 18:51:53 +02:00
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 );
}
}
2026-09-16 16:55:17 +02:00
for ( int i = 0 ; i < pcm . Length ; i ++) pcm [ i ] = ( short )( Math . Sin (( frame * 960 + i ) * Math . PI * 880 / 48000 ) * 8000 );
2026-09-18 18:51:53 +02:00
foreach ( StreamInfo stream in client . LocalStreams )
while (! client . Audio . FeedPcm ( stream . StreamId , pcm , 1 )) await Task . Delay ( 1 , token );
2026-09-16 16:55:17 +02:00
}
2026-09-18 18:51:53 +02:00
await Task . Delay ( TimeSpan . FromMilliseconds ( leadFrames * 20 ), token );
2026-09-16 16:55:17 +02:00
}
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 }));
2026-09-18 18:51:53 +02:00
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 )
2026-09-16 16:55:17 +02:00
{
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 );
2026-09-18 18:51:53 +02:00
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." );
2026-09-16 16:55:17 +02:00
return new ( Value ( "--host" , "127.0.0.1" ), ushort . Parse ( Value ( "--port" , "8384" )), Value ( "--nickname" , Environment . UserName ),
2026-09-18 18:51:53 +02:00
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" ),
2026-09-16 16:55:17 +02:00
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" ))),
2026-09-18 18:51:53 +02:00
TimeSpan . FromSeconds ( int . Parse ( Value ( "--timeout-seconds" , "15" ))), toneDuration );
2026-09-16 16:55:17 +02:00
}
}
}