initial commit
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PublishAot>true</PublishAot>
|
||||
<IlcOptimizationPreference>Speed</IlcOptimizationPreference>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Hush.Config\Hush.Config.csproj" />
|
||||
<ProjectReference Include="..\Hush.Daemon\Hush.Daemon.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Builder;
|
||||
using System.CommandLine.Parsing;
|
||||
using Hush.Cli.Commands;
|
||||
|
||||
namespace Hush.Cli;
|
||||
|
||||
public class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
var rootCommand = CreateRootCommand();
|
||||
var parser = new CommandLineBuilder(rootCommand)
|
||||
.UseDefaults()
|
||||
.Build();
|
||||
|
||||
return await parser.InvokeAsync(args);
|
||||
}
|
||||
|
||||
private static RootCommand CreateRootCommand()
|
||||
{
|
||||
var rootCommand = new RootCommand("Hush - Speech-to-text daemon");
|
||||
|
||||
rootCommand.AddCommand(ToggleCommand.Create());
|
||||
rootCommand.AddCommand(StartCommand.Create());
|
||||
rootCommand.AddCommand(StopCommand.Create());
|
||||
rootCommand.AddCommand(AbortCommand.Create());
|
||||
rootCommand.AddCommand(StatusCommand.Create());
|
||||
rootCommand.AddCommand(DaemonCommand.Create());
|
||||
rootCommand.AddCommand(SetupCommand.Create());
|
||||
rootCommand.AddCommand(LatencyTestCommand.Create());
|
||||
rootCommand.AddCommand(ShowCommand.Create());
|
||||
|
||||
return rootCommand;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.CommandLine;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Hush.Cli.Commands;
|
||||
|
||||
public static class AbortCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("abort", "Abort recording (discard)");
|
||||
command.SetHandler(async (context) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
await client.SendCommandAsync(DaemonProtocol.ABORT);
|
||||
AnsiConsole.MarkupLine("[green]Abort command sent[/]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
|
||||
context.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.CommandLine;
|
||||
using Hush.Daemon;
|
||||
|
||||
namespace Hush.Cli.Commands;
|
||||
|
||||
public static class DaemonCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("daemon", "Start the daemon (foreground, blocking)");
|
||||
command.SetHandler(async () =>
|
||||
{
|
||||
await DaemonService.StartAsync();
|
||||
});
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Hush.Cli.Commands;
|
||||
|
||||
public static class LatencyTestCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("latency-test", "Run a latency test");
|
||||
command.SetHandler(async (context) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
await client.SendCommandAsync(DaemonProtocol.LATENCY_TEST);
|
||||
|
||||
var json = await client.ReceiveRawJsonAsync(TimeSpan.FromSeconds(15));
|
||||
|
||||
if (json == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]No response from daemon[/]");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var error = JsonSerializer.Deserialize(json, DaemonJsonContext.Default.ErrorResponse);
|
||||
if (error?.Error != null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Daemon error: {error.Error}[/]");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var result = JsonSerializer.Deserialize(json, DaemonJsonContext.Default.LatencyResult);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]No response from daemon[/]");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var status = result.TotalMs < 1500 ? "✅" : result.TotalMs < 3000 ? "⚠️" : "❌";
|
||||
var color = result.TotalMs < 1500 ? "green" : result.TotalMs < 3000 ? "yellow" : "red";
|
||||
|
||||
var panel = new Panel($"""
|
||||
[bold]STT Transcription:[/] {result.SttMs,4} ms
|
||||
[bold]LLM Processing:[/] {result.LlmMs,4} ms
|
||||
─────────────────────────────
|
||||
[bold {color}]Total:[/] {result.TotalMs,4} ms {status}
|
||||
""");
|
||||
panel.Header = new PanelHeader("Latency Test Results");
|
||||
|
||||
AnsiConsole.Write(panel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
|
||||
context.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.CommandLine;
|
||||
using Hush.Config;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Hush.Cli.Commands;
|
||||
|
||||
public static class SetupCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("setup", "Interactive setup wizard");
|
||||
command.SetHandler(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = RunWizard();
|
||||
var configManager = new ConfigManager();
|
||||
configManager.Save(config);
|
||||
|
||||
AnsiConsole.MarkupLine("[green]Configuration saved successfully![/]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
|
||||
}
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
private static HushConfig RunWizard()
|
||||
{
|
||||
AnsiConsole.MarkupLine("[bold blue]Welcome to Hush Setup![/]\n");
|
||||
AnsiConsole.MarkupLine("This wizard will help you configure Hush.\n");
|
||||
|
||||
var config = new HushConfig();
|
||||
|
||||
AnsiConsole.MarkupLine("[bold]Step 1: Whisper Provider[/]");
|
||||
config.WhisperProvider = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("Select Whisper provider:")
|
||||
.AddChoices("groq"));
|
||||
|
||||
AnsiConsole.MarkupLine("[bold]Step 2: Groq API Key[/]");
|
||||
config.GroqApiKey = ReadMaskedInput("Enter your Groq API key:");
|
||||
|
||||
AnsiConsole.MarkupLine("[bold]Step 3: LLM Model[/]");
|
||||
config.LlmProvider = "groq";
|
||||
config.LlmModel = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("Select LLM model:")
|
||||
.AddChoices("openai/gpt-oss-20b", "llama-3.1-8b-instant", "openai/gpt-oss-120b"));
|
||||
|
||||
AnsiConsole.MarkupLine("[bold]Step 4: Whisper Model[/]");
|
||||
config.WhisperModel = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("Select Whisper model:")
|
||||
.AddChoices("whisper-large-v3", "whisper-large-v3-turbo"));
|
||||
|
||||
AnsiConsole.MarkupLine("[bold]Step 5: Audio Backend[/]");
|
||||
config.AudioBackend = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("Select audio backend:")
|
||||
.AddChoices("pipewire", "ffmpeg"));
|
||||
|
||||
AnsiConsole.MarkupLine("[bold]Step 6: Typing Backend[/]");
|
||||
config.TypingBackend = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("Select typing backend:")
|
||||
.AddChoices("wtype", "xdotool"));
|
||||
|
||||
AnsiConsole.MarkupLine("[bold]Step 7: Minimum Recording Duration[/]");
|
||||
var minDuration = AnsiConsole.Prompt(
|
||||
new TextPrompt<int>("Enter minimum duration (ms, default 500):")
|
||||
.DefaultValue(500)
|
||||
.Validate(x => x > 0, "Must be greater than 0"));
|
||||
config.MinRecordingDuration = minDuration;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[bold]Configuration Summary:[/]");
|
||||
|
||||
var table = new Table();
|
||||
table.AddColumn("Setting");
|
||||
table.AddColumn("Value");
|
||||
table.AddRow("Whisper Provider", config.WhisperProvider);
|
||||
table.AddRow("Groq API Key", MaskApiKey(config.GroqApiKey));
|
||||
table.AddRow("LLM Model", config.LlmModel);
|
||||
table.AddRow("Whisper Model", config.WhisperModel);
|
||||
table.AddRow("Audio Backend", config.AudioBackend);
|
||||
table.AddRow("Typing Backend", config.TypingBackend);
|
||||
table.AddRow("Min Duration", $"{config.MinRecordingDuration} ms");
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private static string ReadMaskedInput(string prompt)
|
||||
{
|
||||
AnsiConsole.Write(prompt + " ");
|
||||
var input = "";
|
||||
while (true)
|
||||
{
|
||||
var key = Console.ReadKey(true);
|
||||
if (key.Key == ConsoleKey.Enter)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
break;
|
||||
}
|
||||
if (key.Key == ConsoleKey.Backspace && input.Length > 0)
|
||||
{
|
||||
input = input[..^1];
|
||||
AnsiConsole.Write("\b \b");
|
||||
}
|
||||
else if (!char.IsControl(key.KeyChar))
|
||||
{
|
||||
input += key.KeyChar;
|
||||
AnsiConsole.Write("*");
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
private static string MaskApiKey(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key)) return "";
|
||||
if (key.Length <= 8) return "***";
|
||||
return key[..4] + "***" + key[^4..];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Hush.Config;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Hush.Cli.Commands;
|
||||
|
||||
public static class ShowCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("show", "Show current configuration");
|
||||
|
||||
var plainOption = new Option<bool>(["--plain", "-p"], "Show as plain text");
|
||||
var jsonOption = new Option<bool>(["--json", "-j"], "Show as JSON");
|
||||
|
||||
command.AddOption(plainOption);
|
||||
command.AddOption(jsonOption);
|
||||
|
||||
command.SetHandler((context) =>
|
||||
{
|
||||
var plain = context.ParseResult.GetValueForOption(plainOption);
|
||||
var json = context.ParseResult.GetValueForOption(jsonOption);
|
||||
|
||||
try
|
||||
{
|
||||
var configManager = new ConfigManager();
|
||||
var config = configManager.Load();
|
||||
|
||||
if (json)
|
||||
{
|
||||
var jsonOutput = JsonSerializer.Serialize(config, HushConfigContext.Default.HushConfig);
|
||||
Console.WriteLine(jsonOutput);
|
||||
}
|
||||
else if (plain)
|
||||
{
|
||||
AnsiConsole.WriteLine($"whisper_provider={config.WhisperProvider}");
|
||||
AnsiConsole.WriteLine($"groq_api_key={MaskApiKey(config.GroqApiKey)}");
|
||||
AnsiConsole.WriteLine($"llm_provider={config.LlmProvider}");
|
||||
AnsiConsole.WriteLine($"llm_model={config.LlmModel}");
|
||||
AnsiConsole.WriteLine($"whisper_model={config.WhisperModel}");
|
||||
AnsiConsole.WriteLine($"audio_backend={config.AudioBackend}");
|
||||
AnsiConsole.WriteLine($"typing_backend={config.TypingBackend}");
|
||||
AnsiConsole.WriteLine($"min_recording_duration={config.MinRecordingDuration}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var table = new Table();
|
||||
table.AddColumn("Property");
|
||||
table.AddColumn("Value");
|
||||
|
||||
table.AddRow("Whisper Provider", config.WhisperProvider);
|
||||
table.AddRow("Groq API Key", MaskApiKey(config.GroqApiKey));
|
||||
table.AddRow("LLM Provider", config.LlmProvider);
|
||||
table.AddRow("LLM Model", config.LlmModel);
|
||||
table.AddRow("Whisper Model", config.WhisperModel);
|
||||
table.AddRow("Audio Backend", config.AudioBackend);
|
||||
table.AddRow("Typing Backend", config.TypingBackend);
|
||||
table.AddRow("Min Duration", $"{config.MinRecordingDuration} ms");
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
|
||||
}
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static string MaskApiKey(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key)) return "";
|
||||
if (key.Length <= 8) return "***";
|
||||
return key[..4] + "***" + key[^4..];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.CommandLine;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Hush.Cli.Commands;
|
||||
|
||||
public static class StartCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("start", "Start recording");
|
||||
command.SetHandler(async (context) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
await client.SendCommandAsync(DaemonProtocol.START);
|
||||
AnsiConsole.MarkupLine("[green]Start command sent[/]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
|
||||
context.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.CommandLine;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Hush.Cli.Commands;
|
||||
|
||||
public static class StatusCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("status", "Show daemon status");
|
||||
command.SetHandler(async (context) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
await client.SendCommandAsync(DaemonProtocol.STATUS);
|
||||
|
||||
var response = await client.ReceiveJsonAsync<StatusResponse>(TimeSpan.FromSeconds(2));
|
||||
|
||||
if (response == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]No response from daemon[/]");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.State == "recording")
|
||||
{
|
||||
var duration = response.DurationMs.HasValue
|
||||
? TimeSpan.FromMilliseconds(response.DurationMs.Value)
|
||||
: TimeSpan.Zero;
|
||||
AnsiConsole.MarkupLine($"[yellow]● Recording[/] ({duration.Hours:00}:{duration.Minutes:00}:{duration.Seconds:00})");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]○ Idle[/]");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
|
||||
context.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.CommandLine;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Hush.Cli.Commands;
|
||||
|
||||
public static class StopCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("stop", "Stop recording and process");
|
||||
command.SetHandler(async (context) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
await client.SendCommandAsync(DaemonProtocol.STOP);
|
||||
AnsiConsole.MarkupLine("[green]Stop command sent[/]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
|
||||
context.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.CommandLine;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Hush.Cli.Commands;
|
||||
|
||||
public static class ToggleCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("toggle", "Toggle recording (start if idle, stop if recording)");
|
||||
command.SetHandler(async (context) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
await client.SendCommandAsync(DaemonProtocol.TOGGLE);
|
||||
AnsiConsole.MarkupLine("[green]Toggle command sent[/]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
|
||||
context.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Hush.Daemon;
|
||||
|
||||
namespace Hush.Cli;
|
||||
|
||||
public class SocketClient : IAsyncDisposable
|
||||
{
|
||||
private readonly UnixDomainSocketEndPoint _endPoint;
|
||||
private readonly Socket _socket;
|
||||
|
||||
public SocketClient()
|
||||
{
|
||||
var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR");
|
||||
var baseDir = string.IsNullOrEmpty(runtimeDir) ? Path.GetTempPath() : runtimeDir;
|
||||
var socketPath = Path.Combine(baseDir, "hush.sock");
|
||||
|
||||
_endPoint = new UnixDomainSocketEndPoint(socketPath);
|
||||
_socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(TimeSpan timeout)
|
||||
{
|
||||
var cts = new CancellationTokenSource(timeout);
|
||||
await _socket.ConnectAsync(_endPoint, cts.Token);
|
||||
}
|
||||
|
||||
public async Task SendCommandAsync(byte command)
|
||||
{
|
||||
await _socket.SendAsync(new[] { command }, SocketFlags.None);
|
||||
}
|
||||
|
||||
public async Task<T?> ReceiveJsonAsync<T>(TimeSpan timeout)
|
||||
{
|
||||
var cts = new CancellationTokenSource(timeout);
|
||||
var buffer = new byte[4096];
|
||||
var bytesRead = await _socket.ReceiveAsync(buffer, SocketFlags.None, cts.Token);
|
||||
|
||||
if (bytesRead == 0)
|
||||
return default;
|
||||
|
||||
var json = Encoding.UTF8.GetString(buffer, 0, bytesRead);
|
||||
return (T?)JsonSerializer.Deserialize(json, typeof(T), DaemonJsonContext.Default);
|
||||
}
|
||||
|
||||
public async Task<string?> ReceiveRawJsonAsync(TimeSpan timeout)
|
||||
{
|
||||
var cts = new CancellationTokenSource(timeout);
|
||||
var buffer = new byte[4096];
|
||||
var bytesRead = await _socket.ReceiveAsync(buffer, SocketFlags.None, cts.Token);
|
||||
|
||||
if (bytesRead == 0)
|
||||
return null;
|
||||
|
||||
return Encoding.UTF8.GetString(buffer, 0, bytesRead);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_socket.Dispose();
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user