initial commit

This commit is contained in:
2026-03-22 02:25:16 +01:00
commit eb72820ce9
42 changed files with 2506 additions and 0 deletions
+22
View File
@@ -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>
+36
View File
@@ -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;
}
}
+29
View File
@@ -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;
}
}
+17
View File
@@ -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;
}
}
+129
View File
@@ -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..];
}
}
+79
View File
@@ -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..];
}
}
+29
View File
@@ -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;
}
}
+49
View File
@@ -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;
}
}
+29
View File
@@ -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;
}
}
+29
View File
@@ -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;
}
}
+64
View File
@@ -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;
}
}