commit eb72820ce9031f2f3511fa22ec4b58c148b596f2 Author: TomiEckert Date: Sun Mar 22 02:25:16 2026 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4afaef --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*/bin +*/obj +.vs +.vscode +.idea + diff --git a/Hush.Audio/Hush.Audio.csproj b/Hush.Audio/Hush.Audio.csproj new file mode 100644 index 0000000..b32d699 --- /dev/null +++ b/Hush.Audio/Hush.Audio.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + enable + true + true + true + + + diff --git a/Hush.Audio/src/FfmpegAudioRecorder.cs b/Hush.Audio/src/FfmpegAudioRecorder.cs new file mode 100644 index 0000000..d603a19 --- /dev/null +++ b/Hush.Audio/src/FfmpegAudioRecorder.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; + +namespace Hush.Audio; + +public class FfmpegAudioRecorder : IAudioRecorder +{ + private Process? _process; + + public bool IsRecording => _process != null && !_process.HasExited; + + public Task StartRecording(string path) + { + if (IsRecording) + throw new InvalidOperationException("Recording is already in progress"); + + _process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = $"-f pulse -i default -ac 1 -ar 16000 -f wav \"{path}\" -y", + UseShellExecute = false, + CreateNoWindow = true + } + }; + + _process.Start(); + return Task.CompletedTask; + } + + public async Task StopRecording() + { + if (_process == null || _process.HasExited) + return; + + _process.Kill(); + await _process.WaitForExitAsync(); + _process.Dispose(); + _process = null; + } +} \ No newline at end of file diff --git a/Hush.Audio/src/IAudioRecorder.cs b/Hush.Audio/src/IAudioRecorder.cs new file mode 100644 index 0000000..52aabd4 --- /dev/null +++ b/Hush.Audio/src/IAudioRecorder.cs @@ -0,0 +1,8 @@ +namespace Hush.Audio; + +public interface IAudioRecorder +{ + Task StartRecording(string path); + Task StopRecording(); + bool IsRecording { get; } +} \ No newline at end of file diff --git a/Hush.Audio/src/PipewireAudioRecorder.cs b/Hush.Audio/src/PipewireAudioRecorder.cs new file mode 100644 index 0000000..bcedcc6 --- /dev/null +++ b/Hush.Audio/src/PipewireAudioRecorder.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; + +namespace Hush.Audio; + +public class PipewireAudioRecorder : IAudioRecorder +{ + private Process? _process; + private string? _currentPath; + + public bool IsRecording => _process != null && !_process.HasExited; + + public Task StartRecording(string path) + { + if (IsRecording) + throw new InvalidOperationException("Recording is already in progress"); + + _currentPath = path; + + _process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "pw-record", + Arguments = $"\"{_currentPath}\" --rate=16000 --channels=1", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + _process.Start(); + + return Task.CompletedTask; + } + + public async Task StopRecording() + { + if (!IsRecording || _process == null) + return; + + _process.Kill(); + await _process.WaitForExitAsync(); + + _process.Dispose(); + _process = null; + _currentPath = null; + } +} \ No newline at end of file diff --git a/Hush.Cli/Hush.Cli.csproj b/Hush.Cli/Hush.Cli.csproj new file mode 100644 index 0000000..1cd341c --- /dev/null +++ b/Hush.Cli/Hush.Cli.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + true + Speed + + + + + + + + + + + + + diff --git a/Hush.Cli/Program.cs b/Hush.Cli/Program.cs new file mode 100644 index 0000000..d538051 --- /dev/null +++ b/Hush.Cli/Program.cs @@ -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 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; + } +} diff --git a/Hush.Cli/src/Commands/AbortCommand.cs b/Hush.Cli/src/Commands/AbortCommand.cs new file mode 100644 index 0000000..a29806d --- /dev/null +++ b/Hush.Cli/src/Commands/AbortCommand.cs @@ -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; + } +} diff --git a/Hush.Cli/src/Commands/DaemonCommand.cs b/Hush.Cli/src/Commands/DaemonCommand.cs new file mode 100644 index 0000000..4d07f5c --- /dev/null +++ b/Hush.Cli/src/Commands/DaemonCommand.cs @@ -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; + } +} diff --git a/Hush.Cli/src/Commands/LatencyTestCommand.cs b/Hush.Cli/src/Commands/LatencyTestCommand.cs new file mode 100644 index 0000000..eec8742 --- /dev/null +++ b/Hush.Cli/src/Commands/LatencyTestCommand.cs @@ -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; + } +} diff --git a/Hush.Cli/src/Commands/SetupCommand.cs b/Hush.Cli/src/Commands/SetupCommand.cs new file mode 100644 index 0000000..2c4f7dc --- /dev/null +++ b/Hush.Cli/src/Commands/SetupCommand.cs @@ -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() + .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() + .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() + .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() + .Title("Select audio backend:") + .AddChoices("pipewire", "ffmpeg")); + + AnsiConsole.MarkupLine("[bold]Step 6: Typing Backend[/]"); + config.TypingBackend = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select typing backend:") + .AddChoices("wtype", "xdotool")); + + AnsiConsole.MarkupLine("[bold]Step 7: Minimum Recording Duration[/]"); + var minDuration = AnsiConsole.Prompt( + new TextPrompt("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..]; + } +} diff --git a/Hush.Cli/src/Commands/ShowCommand.cs b/Hush.Cli/src/Commands/ShowCommand.cs new file mode 100644 index 0000000..bf8704d --- /dev/null +++ b/Hush.Cli/src/Commands/ShowCommand.cs @@ -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(["--plain", "-p"], "Show as plain text"); + var jsonOption = new Option(["--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..]; + } +} diff --git a/Hush.Cli/src/Commands/StartCommand.cs b/Hush.Cli/src/Commands/StartCommand.cs new file mode 100644 index 0000000..10aabec --- /dev/null +++ b/Hush.Cli/src/Commands/StartCommand.cs @@ -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; + } +} diff --git a/Hush.Cli/src/Commands/StatusCommand.cs b/Hush.Cli/src/Commands/StatusCommand.cs new file mode 100644 index 0000000..778b475 --- /dev/null +++ b/Hush.Cli/src/Commands/StatusCommand.cs @@ -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(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; + } +} diff --git a/Hush.Cli/src/Commands/StopCommand.cs b/Hush.Cli/src/Commands/StopCommand.cs new file mode 100644 index 0000000..03bf681 --- /dev/null +++ b/Hush.Cli/src/Commands/StopCommand.cs @@ -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; + } +} diff --git a/Hush.Cli/src/Commands/ToggleCommand.cs b/Hush.Cli/src/Commands/ToggleCommand.cs new file mode 100644 index 0000000..6b8f6dd --- /dev/null +++ b/Hush.Cli/src/Commands/ToggleCommand.cs @@ -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; + } +} diff --git a/Hush.Cli/src/SocketClient.cs b/Hush.Cli/src/SocketClient.cs new file mode 100644 index 0000000..18216f2 --- /dev/null +++ b/Hush.Cli/src/SocketClient.cs @@ -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 ReceiveJsonAsync(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 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; + } +} diff --git a/Hush.Config/Hush.Config.csproj b/Hush.Config/Hush.Config.csproj new file mode 100644 index 0000000..6378066 --- /dev/null +++ b/Hush.Config/Hush.Config.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + true + true + true + + + + + + + diff --git a/Hush.Config/src/ConfigManager.cs b/Hush.Config/src/ConfigManager.cs new file mode 100644 index 0000000..6686f30 --- /dev/null +++ b/Hush.Config/src/ConfigManager.cs @@ -0,0 +1,84 @@ +using Tomlyn; +using Tomlyn.Model; + +namespace Hush.Config; + +public class ConfigManager +{ + private readonly string _configDir; + private readonly string _configPath; + + public ConfigManager() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + _configDir = Path.Combine(homeDir, ".config", "hush"); + _configPath = Path.Combine(_configDir, "config"); + } + + public HushConfig Load() + { + if (!File.Exists(_configPath)) + { + return new HushConfig(); + } + + try + { + var toml = File.ReadAllText(_configPath); + var model = Toml.ToModel(toml); + + var config = new HushConfig(); + + if (model.TryGetValue("groq_api_key", out var groqKey)) config.GroqApiKey = groqKey.ToString() ?? string.Empty; + if (model.TryGetValue("together_api_key", out var togetherKey)) config.TogetherApiKey = togetherKey.ToString() ?? string.Empty; + if (model.TryGetValue("cerebras_api_key", out var cerebrasKey)) config.CerebrasApiKey = cerebrasKey.ToString() ?? string.Empty; + if (model.TryGetValue("fireworks_api_key", out var fireworksKey)) config.FireworksApiKey = fireworksKey.ToString() ?? string.Empty; + + if (model.TryGetValue("llm_provider", out var llmProvider)) config.LlmProvider = llmProvider.ToString() ?? "groq"; + if (model.TryGetValue("whisper_provider", out var whisperProvider)) config.WhisperProvider = whisperProvider.ToString() ?? "groq"; + if (model.TryGetValue("typing_backend", out var typingBackend)) config.TypingBackend = typingBackend.ToString() ?? "wtype"; + if (model.TryGetValue("audio_backend", out var audioBackend)) config.AudioBackend = audioBackend.ToString() ?? "pw-record"; + + if (model.TryGetValue("llm_model", out var llmModel)) config.LlmModel = llmModel.ToString() ?? "openai/gpt-oss-20b"; + if (model.TryGetValue("whisper_model", out var whisperModel)) config.WhisperModel = whisperModel.ToString() ?? "whisper-large-v3-turbo"; + if (model.TryGetValue("reasoning_effort", out var reasoningEffort)) config.ReasoningEffort = reasoningEffort.ToString() ?? "none"; + + if (model.TryGetValue("min_recording_duration", out var minDuration)) config.MinRecordingDuration = Convert.ToInt32(minDuration); + if (model.TryGetValue("whisper_language", out var language)) config.WhisperLanguage = language.ToString() ?? string.Empty; + + return config; + } + catch + { + return new HushConfig(); + } + } + + public void Save(HushConfig config) + { + Directory.CreateDirectory(_configDir); + + var model = new TomlTable + { + ["groq_api_key"] = config.GroqApiKey, + ["together_api_key"] = config.TogetherApiKey, + ["cerebras_api_key"] = config.CerebrasApiKey, + ["fireworks_api_key"] = config.FireworksApiKey, + + ["llm_provider"] = config.LlmProvider, + ["whisper_provider"] = config.WhisperProvider, + ["typing_backend"] = config.TypingBackend, + ["audio_backend"] = config.AudioBackend, + + ["llm_model"] = config.LlmModel, + ["whisper_model"] = config.WhisperModel, + ["reasoning_effort"] = config.ReasoningEffort, + + ["min_recording_duration"] = config.MinRecordingDuration, + ["whisper_language"] = config.WhisperLanguage + }; + + var toml = Toml.FromModel(model); + File.WriteAllText(_configPath, toml); + } +} diff --git a/Hush.Config/src/HushConfig.cs b/Hush.Config/src/HushConfig.cs new file mode 100644 index 0000000..0f26d37 --- /dev/null +++ b/Hush.Config/src/HushConfig.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace Hush.Config; + +public class HushConfig +{ + public string GroqApiKey { get; set; } = string.Empty; + public string TogetherApiKey { get; set; } = string.Empty; + public string CerebrasApiKey { get; set; } = string.Empty; + public string FireworksApiKey { get; set; } = string.Empty; + + public string LlmProvider { get; set; } = "groq"; + public string WhisperProvider { get; set; } = "groq"; + public string TypingBackend { get; set; } = "wtype"; + public string AudioBackend { get; set; } = "pw-record"; + + public string LlmModel { get; set; } = "openai/gpt-oss-20b"; + public string WhisperModel { get; set; } = "whisper-large-v3-turbo"; + public string ReasoningEffort { get; set; } = "none"; + + public int MinRecordingDuration { get; set; } = 500; + public string WhisperLanguage { get; set; } = string.Empty; +} + +[JsonSerializable(typeof(HushConfig))] +public partial class HushConfigContext : JsonSerializerContext; diff --git a/Hush.Daemon/Hush.Daemon.csproj b/Hush.Daemon/Hush.Daemon.csproj new file mode 100644 index 0000000..4823bb7 --- /dev/null +++ b/Hush.Daemon/Hush.Daemon.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + true + true + true + + + + + + + + + + diff --git a/Hush.Daemon/src/DaemonProtocol.cs b/Hush.Daemon/src/DaemonProtocol.cs new file mode 100644 index 0000000..e81be68 --- /dev/null +++ b/Hush.Daemon/src/DaemonProtocol.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace Hush.Daemon; + +public static class DaemonProtocol +{ + public const byte START = 1; // Start recording + public const byte STOP = 2; // Stop recording, process, type + public const byte ABORT = 3; // Cancel recording + public const byte TOGGLE = 4; // Start if idle, stop if recording + public const byte STATUS = 5; // Return state as JSON + public const byte LATENCY_TEST = 6; // Run latency test, return timing JSON +} + +public record LatencyResult(int SttMs, int LlmMs, int TotalMs); + +public record StatusResponse(string State, long? DurationMs = null); + +public record ErrorResponse(string Error); + +[JsonSerializable(typeof(LatencyResult))] +[JsonSerializable(typeof(StatusResponse))] +[JsonSerializable(typeof(ErrorResponse))] +[JsonSerializable(typeof(string))] +public partial class DaemonJsonContext : JsonSerializerContext; diff --git a/Hush.Daemon/src/DaemonService.cs b/Hush.Daemon/src/DaemonService.cs new file mode 100644 index 0000000..de3ceae --- /dev/null +++ b/Hush.Daemon/src/DaemonService.cs @@ -0,0 +1,217 @@ +using System.Net.Sockets; +using Hush.Config; + +namespace Hush.Daemon; + +public class DaemonService +{ + private static FileStream? _lockFile; + + public static string GetSocketPath() + { + var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + var baseDir = string.IsNullOrEmpty(runtimeDir) ? Path.GetTempPath() : runtimeDir; + return Path.Combine(baseDir, "hush.sock"); + } + + public static async Task StartAsync() + { + var lockPath = GetLockFilePath(); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + _lockFile = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException) + { + Console.WriteLine("Hush daemon is already running."); + return; + } + + var socketPath = GetSocketPath(); + + if (File.Exists(socketPath)) + { + try { File.Delete(socketPath); } catch { } + } + + var configManager = new ConfigManager(); + var orchestrator = new Orchestrator(configManager); + + using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + var endPoint = new UnixDomainSocketEndPoint(socketPath); + + try + { + socket.Bind(endPoint); + socket.Listen(10); + Console.WriteLine($"Hush daemon started, listening on {socketPath}"); + + while (true) + { + var client = await socket.AcceptAsync(); + _ = Task.Run(() => HandleClientAsync(client, orchestrator)); + } + } + catch (Exception ex) + { + Console.WriteLine($"Daemon error: {ex.Message}"); + } + finally + { + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + } + } + } + + private static async Task HandleClientAsync(Socket client, Orchestrator orchestrator) + { + try + { + var buffer = new byte[1]; + var bytesRead = await client.ReceiveAsync(buffer, SocketFlags.None); + + if (bytesRead == 0) + { + client.Close(); + return; + } + + var cmd = buffer[0]; + + switch (cmd) + { + case DaemonProtocol.START: + await HandleStartAsync(orchestrator); + break; + case DaemonProtocol.STOP: + await HandleStopAsync(orchestrator); + break; + case DaemonProtocol.ABORT: + await HandleAbortAsync(orchestrator); + break; + case DaemonProtocol.TOGGLE: + await HandleToggleAsync(orchestrator); + break; + case DaemonProtocol.STATUS: + await HandleStatusAsync(client, orchestrator); + break; + case DaemonProtocol.LATENCY_TEST: + await HandleLatencyTestAsync(client, orchestrator); + break; + } + } + catch (Exception ex) + { + Console.WriteLine($"HandleClient error: {ex.Message}"); + } + finally + { + client.Close(); + } + } + + private static async Task HandleStartAsync(Orchestrator orchestrator) + { + if (orchestrator.IsRecording) + { + Console.WriteLine("Already recording"); + return; + } + + try + { + await orchestrator.StartRecordingAsync(); + Console.WriteLine("Recording started"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to start recording: {ex.Message}"); + } + } + + private static async Task HandleStopAsync(Orchestrator orchestrator) + { + if (!orchestrator.IsRecording) + { + Console.WriteLine("Not recording"); + return; + } + + try + { + await orchestrator.StopAndProcessAsync(); + Console.WriteLine("Recording stopped and processed"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to stop recording: {ex.Message}"); + } + } + + private static async Task HandleAbortAsync(Orchestrator orchestrator) + { + if (!orchestrator.IsRecording) + { + Console.WriteLine("Not recording"); + return; + } + + await orchestrator.AbortAsync(); + Console.WriteLine("Recording aborted"); + } + + private static async Task HandleToggleAsync(Orchestrator orchestrator) + { + if (orchestrator.IsRecording) + { + await HandleStopAsync(orchestrator); + } + else + { + await HandleStartAsync(orchestrator); + } + } + + private static async Task HandleStatusAsync(Socket client, Orchestrator orchestrator) + { + var isRecording = orchestrator.IsRecording; + var durationMs = orchestrator.GetRecordingDuration()?.TotalMilliseconds; + + var responseObj = isRecording + ? new StatusResponse("recording", (long?)durationMs) + : new StatusResponse("idle"); + + var json = System.Text.Json.JsonSerializer.Serialize(responseObj, DaemonJsonContext.Default.StatusResponse); + var response = System.Text.Encoding.UTF8.GetBytes(json); + await client.SendAsync(response, SocketFlags.None); + } + + private static async Task HandleLatencyTestAsync(Socket client, Orchestrator orchestrator) + { + try + { + var result = await orchestrator.RunLatencyTestAsync(); + var json = System.Text.Json.JsonSerializer.Serialize(result, DaemonJsonContext.Default.LatencyResult); + var response = System.Text.Encoding.UTF8.GetBytes(json); + await client.SendAsync(response, SocketFlags.None); + } + catch (Exception ex) + { + var error = new ErrorResponse(ex.Message); + var json = System.Text.Json.JsonSerializer.Serialize(error, DaemonJsonContext.Default.ErrorResponse); + var response = System.Text.Encoding.UTF8.GetBytes(json); + await client.SendAsync(response, SocketFlags.None); + } + } + + private static string GetLockFilePath() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var appDir = Path.Combine(homeDir, "hush"); + return Path.Combine(appDir, "daemon.lock"); + } +} diff --git a/Hush.Daemon/src/Orchestrator.cs b/Hush.Daemon/src/Orchestrator.cs new file mode 100644 index 0000000..1c312f4 --- /dev/null +++ b/Hush.Daemon/src/Orchestrator.cs @@ -0,0 +1,314 @@ +using Hush.Audio; +using Hush.Config; +using Hush.Input; +using Hush.Providers.Interfaces; +using Hush.Providers.Providers; + +namespace Hush.Daemon; + +public class Orchestrator +{ + private readonly ConfigManager _configManager; + private readonly IAudioRecorder _recorder; + private IAudioToTextProvider? _audioToTextProvider; + private ITextStreamingProvider? _textProvider; + private ITextInput? _textInput; + + private string? _recordingPath; + private DateTime? _recordingStartTime; + private bool _isRecording; + private readonly Lock _lock = new(); + + public Orchestrator(ConfigManager configManager) + { + _configManager = configManager; + _recorder = CreateAudioRecorder(); + } + + public bool IsRecording + { + get + { + lock (_lock) + { + return _isRecording && _recorder.IsRecording; + } + } + } + + public TimeSpan? GetRecordingDuration() + { + lock (_lock) + { + if (!_isRecording || !_recordingStartTime.HasValue) + return null; + + return DateTime.UtcNow - _recordingStartTime.Value; + } + } + + public Task StartRecordingAsync() + { + lock (_lock) + { + if (_isRecording) + throw new InvalidOperationException("Recording is already in progress"); + + _recordingPath = Path.Combine(Path.GetTempPath(), $"hush_recording_{Guid.NewGuid()}.wav"); + _recordingStartTime = DateTime.UtcNow; + _isRecording = true; + } + + return _recorder.StartRecording(_recordingPath); + } + + public async Task StopAndProcessAsync() + { + string? recordingPath; + DateTime? recordingStartTime; + + lock (_lock) + { + if (!_isRecording) + return; + + recordingPath = _recordingPath; + recordingStartTime = _recordingStartTime; + _isRecording = false; + } + + await _recorder.StopRecording(); + + if (string.IsNullOrEmpty(recordingPath) || !File.Exists(recordingPath)) + { + SendNotification("Error", "Recording file not found"); + return; + } + + try + { + var config = _configManager.Load(); + + var recordingDuration = recordingStartTime.HasValue + ? DateTime.UtcNow - recordingStartTime.Value + : TimeSpan.Zero; + var minDuration = TimeSpan.FromMilliseconds(config.MinRecordingDuration); + if (recordingDuration < minDuration) + { + SendNotification("Hush", "Recording too short, ignored"); + File.Delete(recordingPath); + return; + } + + var transcription = await TranscribeAsync(recordingPath, config); + var processedText = await ProcessWithLlmAsync(transcription, config); + + await TypeAsync(processedText, config); + + File.Delete(recordingPath); + } + catch (Exception ex) + { + SendNotification("Hush Error", ex.Message); + } + } + + public Task AbortAsync() + { + string? recordingPath; + + lock (_lock) + { + if (!_isRecording) + return Task.CompletedTask; + + recordingPath = _recordingPath; + _isRecording = false; + } + + _ = _recorder.StopRecording(); + + if (!string.IsNullOrEmpty(recordingPath) && File.Exists(recordingPath)) + { + File.Delete(recordingPath); + } + + return Task.CompletedTask; + } + + private async Task TranscribeAsync(string path, HushConfig config) + { + var provider = GetAudioToTextProvider(config); + + await using var stream = File.OpenRead(path); + return await provider.TranscribeAsync(stream, config.WhisperModel); + } + + private async Task ProcessWithLlmAsync(string text, HushConfig config) + { + var provider = GetTextProvider(config); + + var prompt = $""" + Process this spoken text for clarity and correctness. Fix any errors, add proper punctuation, and make it read naturally. Keep the original meaning intact. + + Text: {text} + """; + + return await provider.CompleteTextAsync(prompt, config.LlmModel); + } + + private async Task TypeAsync(string text, HushConfig config) + { + var input = GetTextInput(config); + await input.TypeString(text); + } + + private IAudioToTextProvider GetAudioToTextProvider(HushConfig config) + { + if (_audioToTextProvider != null) + return _audioToTextProvider; + + _audioToTextProvider = config.WhisperProvider switch + { + "groq" => string.IsNullOrEmpty(config.GroqApiKey) + ? throw new InvalidOperationException("Groq API key is required for Whisper transcription") + : new GroqProvider(config.GroqApiKey), + _ => throw new InvalidOperationException($"Unsupported Whisper provider: {config.WhisperProvider}") + }; + + return _audioToTextProvider; + } + + private ITextStreamingProvider GetTextProvider(HushConfig config) + { + if (_textProvider != null) + return _textProvider; + + _textProvider = config.LlmProvider switch + { + "groq" => string.IsNullOrEmpty(config.GroqApiKey) + ? throw new InvalidOperationException("Groq API key is required for LLM") + : new GroqProvider(config.GroqApiKey), + _ => throw new InvalidOperationException($"Unsupported LLM provider: {config.LlmProvider}") + }; + + return _textProvider; + } + + private ITextInput GetTextInput(HushConfig config) + { + if (_textInput != null) + return _textInput; + + _textInput = config.TypingBackend switch + { + "xdotool" => new XdotoolInput(), + _ => new WtypeInput() + }; + + return _textInput; + } + + private IAudioRecorder CreateAudioRecorder() + { + var config = _configManager.Load(); + + return config.AudioBackend switch + { + "ffmpeg" => new FfmpegAudioRecorder(), + _ => new PipewireAudioRecorder() + }; + } + + private static void SendNotification(string title, string message) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "notify-send", + Arguments = $"\"{title}\" \"{message}\"", + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.Start(); + process.WaitForExit(); + } + catch + { + Console.WriteLine($"[Notification] {title}: {message}"); + } + } + + public async Task RunLatencyTestAsync() + { + var config = _configManager.Load(); + + var sttStopwatch = System.Diagnostics.Stopwatch.StartNew(); + var llmStopwatch = new System.Diagnostics.Stopwatch(); + + var wavBytes = GenerateSilentWav(1.0); + await using var wavStream = new MemoryStream(wavBytes); + + var transcription = await TranscribeStreamAsync(wavStream, config); + + sttStopwatch.Stop(); + llmStopwatch.Start(); + + var processedText = await ProcessWithLlmAsync(transcription, config); + + llmStopwatch.Stop(); + + return new LatencyResult( + (int)sttStopwatch.ElapsedMilliseconds, + (int)llmStopwatch.ElapsedMilliseconds, + (int)(sttStopwatch.ElapsedMilliseconds + llmStopwatch.ElapsedMilliseconds) + ); + } + + private async Task TranscribeStreamAsync(Stream stream, HushConfig config) + { + var provider = GetAudioToTextProvider(config); + return await provider.TranscribeAsync(stream, config.WhisperModel); + } + + private static byte[] GenerateSilentWav(double durationSeconds) + { + int sampleRate = 16000; + short bitsPerSample = 16; + int channels = 1; + + int dataChunkSize = (int)(sampleRate * durationSeconds * channels * (bitsPerSample / 8)); + int fileSize = 36 + dataChunkSize; + + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write("RIFF"u8.ToArray()); + writer.Write(fileSize); + writer.Write("WAVE"u8.ToArray()); + + writer.Write("fmt "u8.ToArray()); + writer.Write(16); + writer.Write((short)1); + writer.Write((short)channels); + writer.Write(sampleRate); + writer.Write(sampleRate * channels * (bitsPerSample / 8)); + writer.Write((short)(channels * (bitsPerSample / 8))); + writer.Write(bitsPerSample); + + writer.Write("data"u8.ToArray()); + writer.Write(dataChunkSize); + + int samples = (int)(sampleRate * durationSeconds); + for (int i = 0; i < samples; i++) + { + writer.Write((short)0); + } + + return ms.ToArray(); + } +} diff --git a/Hush.Input/Hush.Input.csproj b/Hush.Input/Hush.Input.csproj new file mode 100644 index 0000000..825839c --- /dev/null +++ b/Hush.Input/Hush.Input.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + enable + true + true + true + + + diff --git a/Hush.Input/src/ITextInput.cs b/Hush.Input/src/ITextInput.cs new file mode 100644 index 0000000..4fe90fc --- /dev/null +++ b/Hush.Input/src/ITextInput.cs @@ -0,0 +1,6 @@ +namespace Hush.Input; + +public interface ITextInput +{ + Task TypeString(string text); +} diff --git a/Hush.Input/src/WtypeInput.cs b/Hush.Input/src/WtypeInput.cs new file mode 100644 index 0000000..256d830 --- /dev/null +++ b/Hush.Input/src/WtypeInput.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; + +namespace Hush.Input; + +public class WtypeInput : ITextInput +{ + public async Task TypeString(string text) + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "wtype", + Arguments = $"\"{text}\"", + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + await process.WaitForExitAsync(); + process.Dispose(); + } +} diff --git a/Hush.Input/src/XdotoolInput.cs b/Hush.Input/src/XdotoolInput.cs new file mode 100644 index 0000000..b85b13f --- /dev/null +++ b/Hush.Input/src/XdotoolInput.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; + +namespace Hush.Input; + +public class XdotoolInput : ITextInput +{ + public async Task TypeString(string text) + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "xdotool", + Arguments = $"type --delay 10 -- \"{text}\"", + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + await process.WaitForExitAsync(); + process.Dispose(); + } +} diff --git a/Hush.Providers/Hush.Providers.csproj b/Hush.Providers/Hush.Providers.csproj new file mode 100644 index 0000000..8a9732e --- /dev/null +++ b/Hush.Providers/Hush.Providers.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + \ No newline at end of file diff --git a/Hush.Providers/src/Interfaces/IAudioToTextProvider.cs b/Hush.Providers/src/Interfaces/IAudioToTextProvider.cs new file mode 100644 index 0000000..1512d15 --- /dev/null +++ b/Hush.Providers/src/Interfaces/IAudioToTextProvider.cs @@ -0,0 +1,19 @@ +namespace Hush.Providers.Interfaces; + +/// +/// Interface for audio-to-text transcription functionality. +/// +public interface IAudioToTextProvider +{ + /// + /// Transcribes audio from a stream to text. + /// + /// The audio stream to transcribe + /// The model name to use for transcription (e.g., whisper-large-v3) + /// Cancellation token + /// The transcribed text + Task TranscribeAsync( + Stream audioStream, + string modelName, + CancellationToken cancellationToken = default); +} diff --git a/Hush.Providers/src/Interfaces/ITextStreamingProvider.cs b/Hush.Providers/src/Interfaces/ITextStreamingProvider.cs new file mode 100644 index 0000000..3a0e80c --- /dev/null +++ b/Hush.Providers/src/Interfaces/ITextStreamingProvider.cs @@ -0,0 +1,31 @@ +namespace Hush.Providers.Interfaces; + +/// +/// Interface for text generation with both synchronous and streaming capabilities. +/// +public interface ITextStreamingProvider +{ + /// + /// Generates text completion for a given prompt. + /// + /// The input prompt + /// The model name to use (e.g., llama-3.3-70b-versatile) + /// Cancellation token + /// The generated text + Task CompleteTextAsync( + string prompt, + string modelName, + CancellationToken cancellationToken = default); + + /// + /// Streams text generation for a given prompt. + /// + /// The input prompt + /// The model name to use (e.g., llama-3.3-70b-versatile) + /// Cancellation token + /// Async enumerable of text chunks + IAsyncEnumerable StreamTextAsync( + string prompt, + string modelName, + CancellationToken cancellationToken = default); +} diff --git a/Hush.Providers/src/Models/Request/ChatCompletionRequest.cs b/Hush.Providers/src/Models/Request/ChatCompletionRequest.cs new file mode 100644 index 0000000..4bd3ce8 --- /dev/null +++ b/Hush.Providers/src/Models/Request/ChatCompletionRequest.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; + +namespace Hush.Providers.Models.Request; + +/// +/// Request model for Groq chat completion API. +/// +public record ChatCompletionRequest +{ + /// + /// A list of messages comprising the conversation so far. + /// + [JsonPropertyName("messages")] + public required List Messages { get; init; } + + /// + /// ID of the model to use. + /// + [JsonPropertyName("model")] + public required string Model { get; init; } + + /// + /// Whether to stream the response. + /// + [JsonPropertyName("stream")] + public bool Stream { get; init; } = false; + + /// + /// Sampling temperature (0 to 2). + /// + [JsonPropertyName("temperature")] + public float? Temperature { get; init; } = 1.0f; + + /// + /// Nucleus sampling cutoff (0 to 1). + /// + [JsonPropertyName("top_p")] + public float? TopP { get; init; } = 1.0f; + + /// + /// Maximum number of tokens to generate. + /// + [JsonPropertyName("max_completion_tokens")] + public int? MaxCompletionTokens { get; init; } + + /// + /// Up to 4 sequences where the API stops generating tokens. + /// + [JsonPropertyName("stop")] + public string[]? Stop { get; init; } + + /// + /// Unique identifier representing your end-user. + /// + [JsonPropertyName("user")] + public string? User { get; init; } +} + +/// +/// A message in the chat conversation. +/// +public record Message +{ + /// + /// The role of the message author. + /// + [JsonPropertyName("role")] + public required string Role { get; init; } + + /// + /// The content of the message. + /// + [JsonPropertyName("content")] + public required string Content { get; init; } +} diff --git a/Hush.Providers/src/Models/Request/TranscriptionRequest.cs b/Hush.Providers/src/Models/Request/TranscriptionRequest.cs new file mode 100644 index 0000000..4009038 --- /dev/null +++ b/Hush.Providers/src/Models/Request/TranscriptionRequest.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace Hush.Providers.Models.Request; + +/// +/// Request model for Groq audio transcription API. +/// +public record TranscriptionRequest +{ + /// + /// The model to use for transcription. + /// + [JsonPropertyName("model")] + public required string Model { get; init; } + + /// + /// The language of the audio (ISO-639-1 format). + /// + [JsonPropertyName("language")] + public string? Language { get; init; } + + /// + /// Text to guide the model's style or context. + /// + [JsonPropertyName("prompt")] + public string? Prompt { get; init; } + + /// + /// Response format (json, text, verbose_json). + /// + [JsonPropertyName("response_format")] + public string? ResponseFormat { get; init; } = "json"; + + /// + /// Sampling temperature (0 to 1). + /// + [JsonPropertyName("temperature")] + public float? Temperature { get; init; } = 0.0f; +} diff --git a/Hush.Providers/src/Models/Response/ChatCompletionResponse.cs b/Hush.Providers/src/Models/Response/ChatCompletionResponse.cs new file mode 100644 index 0000000..23719a1 --- /dev/null +++ b/Hush.Providers/src/Models/Response/ChatCompletionResponse.cs @@ -0,0 +1,153 @@ +using System.Text.Json.Serialization; + +namespace Hush.Providers.Models.Response; + +/// +/// Response model for Groq chat completion API. +/// +public record ChatCompletionResponse +{ + /// + /// Unique identifier for the completion. + /// + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// + /// Object type, always "chat.completion". + /// + [JsonPropertyName("object")] + public required string Object { get; init; } + + /// + /// Unix timestamp of creation. + /// + [JsonPropertyName("created")] + public required long Created { get; init; } + + /// + /// Model used. + /// + [JsonPropertyName("model")] + public required string Model { get; init; } + + /// + /// List of completion choices. + /// + [JsonPropertyName("choices")] + public required List Choices { get; init; } + + /// + /// Usage statistics. + /// + [JsonPropertyName("usage")] + public required Usage Usage { get; init; } + + /// + /// Groq-specific metadata. + /// + [JsonPropertyName("x_groq")] + public required GroqMetadata XGroq { get; init; } +} + +/// +/// A completion choice. +/// +public record Choice +{ + /// + /// Index of the choice. + /// + [JsonPropertyName("index")] + public required int Index { get; init; } + + /// + /// The message content. + /// + [JsonPropertyName("message")] + public required Message Message { get; init; } + + /// + /// Reason the model stopped generating tokens. + /// + [JsonPropertyName("finish_reason")] + public required string FinishReason { get; init; } +} + +/// +/// A message in the response. +/// +public record Message +{ + /// + /// The role of the message author. + /// + [JsonPropertyName("role")] + public required string Role { get; init; } + + /// + /// The content of the message. + /// + [JsonPropertyName("content")] + public required string Content { get; init; } +} + + /// + /// Usage statistics for the completion. + /// + public record Usage + { + /// + /// Time spent in queue. + /// + [JsonPropertyName("queue_time")] + public double? QueueTime { get; init; } + + /// + /// Number of tokens in the prompt. + /// + [JsonPropertyName("prompt_tokens")] + public required int PromptTokens { get; init; } + + /// + /// Time spent processing the prompt. + /// + [JsonPropertyName("prompt_time")] + public double? PromptTime { get; init; } + + /// + /// Number of tokens in the completion. + /// + [JsonPropertyName("completion_tokens")] + public required int CompletionTokens { get; init; } + + /// + /// Time spent generating the completion. + /// + [JsonPropertyName("completion_time")] + public double? CompletionTime { get; init; } + + /// + /// Total number of tokens. + /// + [JsonPropertyName("total_tokens")] + public required int TotalTokens { get; init; } + + /// + /// Total time for the request. + /// + [JsonPropertyName("total_time")] + public double? TotalTime { get; init; } + } + +/// +/// Groq-specific metadata. +/// +public record GroqMetadata +{ + /// + /// Request ID. + /// + [JsonPropertyName("id")] + public required string Id { get; init; } +} diff --git a/Hush.Providers/src/Models/Response/TranscriptionResponse.cs b/Hush.Providers/src/Models/Response/TranscriptionResponse.cs new file mode 100644 index 0000000..13ace6d --- /dev/null +++ b/Hush.Providers/src/Models/Response/TranscriptionResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Hush.Providers.Models.Response; + +/// +/// Response model for Groq audio transcription API. +/// +public record TranscriptionResponse +{ + /// + /// The transcribed text. + /// + [JsonPropertyName("text")] + public required string Text { get; init; } + + /// + /// Groq-specific metadata. + /// + [JsonPropertyName("x_groq")] + public required GroqMetadata XGroq { get; init; } +} diff --git a/Hush.Providers/src/Providers/GroqProvider.cs b/Hush.Providers/src/Providers/GroqProvider.cs new file mode 100644 index 0000000..d4aa651 --- /dev/null +++ b/Hush.Providers/src/Providers/GroqProvider.cs @@ -0,0 +1,208 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Hush.Providers.Interfaces; +using Hush.Providers.Models.Request; +using Hush.Providers.Models.Response; +using Hush.Providers.Serialization; + +namespace Hush.Providers.Providers; + +/// +/// Implementation of LLM provider for Groq API. +/// +public class GroqProvider : IAudioToTextProvider, ITextStreamingProvider +{ + private const string ChatCompletionEndpoint = "https://api.groq.com/openai/v1/chat/completions"; + private const string TranscriptionEndpoint = "https://api.groq.com/openai/v1/audio/transcriptions"; + + private readonly HttpClient _httpClient; + private readonly string _apiKey; + + /// + /// Initializes a new instance of the GroqProvider class. + /// + /// The Groq API key + /// Optional HttpClient instance (for testing) + public GroqProvider(string apiKey, HttpClient? httpClient = null) + { + _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); + _httpClient = httpClient ?? new HttpClient(); + } + + /// + public async Task TranscribeAsync( + Stream audioStream, + string modelName, + CancellationToken cancellationToken = default) + { + if (audioStream == null) + throw new ArgumentNullException(nameof(audioStream)); + + if (string.IsNullOrWhiteSpace(modelName)) + throw new ArgumentException("Model name is required", nameof(modelName)); + + var request = new TranscriptionRequest { Model = modelName }; + + using var content = new MultipartFormDataContent(); + content.Add(new StreamContent(audioStream), "file", "audio.wav"); + content.Add(new StringContent(request.Model), "model"); + if (request.ResponseFormat != null) + content.Add(new StringContent(request.ResponseFormat), "response_format"); + if (request.Language != null) + content.Add(new StringContent(request.Language), "language"); + if (request.Prompt != null) + content.Add(new StringContent(request.Prompt), "prompt"); + if (request.Temperature.HasValue) + content.Add(new StringContent(request.Temperature.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)), "temperature"); + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, TranscriptionEndpoint) + { + Content = content + }; + + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + + using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + var result = JsonSerializer.Deserialize( + responseContent, + JsonSourceGeneration.Default.TranscriptionResponse); + + if (result == null) + throw new InvalidOperationException("Failed to deserialize transcription response"); + + return result.Text; + } + + /// + public async Task CompleteTextAsync( + string prompt, + string modelName, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new ArgumentException("Prompt is required", nameof(prompt)); + + if (string.IsNullOrWhiteSpace(modelName)) + throw new ArgumentException("Model name is required", nameof(modelName)); + + var request = new ChatCompletionRequest + { + Model = modelName, + Messages = new List { new Hush.Providers.Models.Request.Message { Role = "user", Content = prompt } } + }; + + var jsonContent = new StringContent( + JsonSerializer.Serialize(request, JsonSourceGeneration.Default.ChatCompletionRequest), + Encoding.UTF8, + "application/json"); + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, ChatCompletionEndpoint) + { + Content = jsonContent + }; + + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + + using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + var result = JsonSerializer.Deserialize(responseContent, JsonSourceGeneration.Default.ChatCompletionResponse); + + if (result == null || result.Choices.Count == 0) + throw new InvalidOperationException("Failed to deserialize chat completion response"); + + return result.Choices[0].Message.Content; + } + + /// + public async IAsyncEnumerable StreamTextAsync( + string prompt, + string modelName, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new ArgumentException("Prompt is required", nameof(prompt)); + + if (string.IsNullOrWhiteSpace(modelName)) + throw new ArgumentException("Model name is required", nameof(modelName)); + + var request = new ChatCompletionRequest + { + Model = modelName, + Stream = true, + Messages = new List { new Hush.Providers.Models.Request.Message { Role = "user", Content = prompt } } + }; + + var jsonContent = new StringContent( + JsonSerializer.Serialize(request, JsonSourceGeneration.Default.ChatCompletionRequest), + Encoding.UTF8, + "application/json"); + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, ChatCompletionEndpoint) + { + Content = jsonContent + }; + + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + + using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var reader = new StreamReader(stream); + + string? line; + while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null) + { + if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ")) + continue; + + var data = line.Substring(6).Trim(); // Remove "data: " prefix + + if (data == "[DONE]") + break; + + var text = ParseTextFromStreamData(data); + if (!string.IsNullOrEmpty(text)) + yield return text; + } + } + + private static string? ParseTextFromStreamData(string data) + { + try + { + using var jsonDoc = JsonDocument.Parse(data); + var choices = jsonDoc.RootElement.GetProperty("choices"); + var choice = choices[0]; + + if (choice.TryGetProperty("delta", out var delta)) + { + if (delta.TryGetProperty("content", out var content)) + { + return content.GetString(); + } + } + else if (choice.TryGetProperty("text", out var text)) + { + return text.GetString(); + } + } + catch (JsonException) + { + // Skip malformed JSON chunks + } + + return null; + } +} diff --git a/Hush.Providers/src/Serialization/JsonSourceGeneration.cs b/Hush.Providers/src/Serialization/JsonSourceGeneration.cs new file mode 100644 index 0000000..3099ff6 --- /dev/null +++ b/Hush.Providers/src/Serialization/JsonSourceGeneration.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Hush.Providers.Serialization; + +/// +/// Source generation context for JSON serialization. +/// +[JsonSerializable(typeof(Models.Response.ChatCompletionResponse))] +[JsonSerializable(typeof(Models.Response.TranscriptionResponse))] +[JsonSerializable(typeof(Models.Response.Choice))] +[JsonSerializable(typeof(Models.Response.Message), TypeInfoPropertyName = "ResponseMessage")] +[JsonSerializable(typeof(Models.Response.Usage))] +[JsonSerializable(typeof(Models.Response.GroqMetadata))] +[JsonSerializable(typeof(Models.Request.ChatCompletionRequest))] +[JsonSerializable(typeof(Models.Request.TranscriptionRequest))] +[JsonSerializable(typeof(Models.Request.Message), TypeInfoPropertyName = "RequestMessage")] +public partial class JsonSourceGeneration : JsonSerializerContext +{ +} diff --git a/Hush.slnx b/Hush.slnx new file mode 100644 index 0000000..c6506b4 --- /dev/null +++ b/Hush.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..c89bdb0 --- /dev/null +++ b/build.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +echo "Building Hush with AOT compilation..." + +dotnet publish Hush.slnx -c Release -r linux-x64 -p:PublishAot=true + +echo "" +echo "Build complete!" +echo "Binary location:" +echo " - Hush.Cli: Hush.Cli/bin/Release/net10.0/linux-x64/publish/Hush.Cli" +echo "" +echo "The daemon is not a separate binary. Start it with: hush daemon" diff --git a/completions/_hush b/completions/_hush new file mode 100644 index 0000000..b498c8b --- /dev/null +++ b/completions/_hush @@ -0,0 +1,38 @@ +#compdef hush + +_hush() { + local state + + _arguments \ + '1: :->command' \ + '*: :->args' \ + && return 0 + + case $state in + command) + local commands=( + 'daemon:Start the daemon in the foreground' + 'start:Begin a new recording' + 'stop:Stop recording and transcribe' + 'abort:Cancel and discard the current recording' + 'toggle:Start recording if idle, stop if recording' + 'status:Show daemon state and recording duration' + 'latency-test:Run a full STT+LLM round-trip latency test' + 'setup:Interactive configuration wizard' + 'show:Display current configuration' + ) + _describe 'command' commands + ;; + args) + case $words[2] in + show) + _arguments \ + '--plain[Show plain key=value pairs instead of a table]' \ + '--json[Show raw JSON output]' + ;; + esac + ;; + esac +} + +_hush "$@" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..88183fd --- /dev/null +++ b/install.sh @@ -0,0 +1,211 @@ +#!/bin/bash + +set -e + +CLI_BIN="Hush.Cli/bin/Release/net10.0/linux-x64/publish/Hush.Cli" +INSTALL_PREFIX="${INSTALL_PREFIX:-/usr}" +BIN_DIR="$INSTALL_PREFIX/bin" +COMPLETION_DIR="$INSTALL_PREFIX/share/zsh/site-functions" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +info() { echo "[install] $*"; } +success() { echo "[install] $*"; } +warn() { echo "[install] WARNING: $*" >&2; } +die() { echo "[install] ERROR: $*" >&2; exit 1; } + +require_root() { + if [[ $EUID -ne 0 ]]; then + die "This script must be run as root (use sudo)." + fi +} + +detect_init() { + if command -v systemctl &>/dev/null && systemctl --version &>/dev/null 2>&1; then + echo "systemd" + elif [[ -f /sbin/openrc-run ]] || [[ -f /usr/sbin/openrc-run ]] || command -v rc-service &>/dev/null; then + echo "openrc" + else + echo "none" + fi +} + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- + +build() { + info "Building Hush with AOT compilation..." + # If running as root (via sudo), build as the real user so that the + # output artifacts are not owned by root. + if [[ $EUID -eq 0 && -n "$SUDO_USER" ]]; then + sudo -u "$SUDO_USER" bash build.sh + else + bash build.sh + fi +} + +# --------------------------------------------------------------------------- +# Install binary +# --------------------------------------------------------------------------- + +install_binary() { + [[ -f "$CLI_BIN" ]] || die "Binary not found: $CLI_BIN — run build.sh first or let install.sh build it." + + info "Installing binary to $BIN_DIR/hush..." + install -Dm755 "$CLI_BIN" "$BIN_DIR/hush" + success "Installed: $BIN_DIR/hush" +} + +# --------------------------------------------------------------------------- +# Zsh completion +# --------------------------------------------------------------------------- + +install_completion() { + info "Installing zsh completion to $COMPLETION_DIR/_hush..." + install -Dm644 completions/_hush "$COMPLETION_DIR/_hush" + success "Zsh completion installed." + info " Add the following to your .zshrc if not already present:" + info " fpath=($COMPLETION_DIR \$fpath)" + info " autoload -Uz compinit && compinit" +} + +# --------------------------------------------------------------------------- +# systemd user service +# --------------------------------------------------------------------------- + +install_systemd() { + info "Installing systemd user service..." + + local run_user="${SUDO_USER:-$USER}" + local user_home uid service_dir service_file + user_home=$(getent passwd "$run_user" | cut -d: -f6) + uid=$(id -u "$run_user") + service_dir="$user_home/.config/systemd/user" + service_file="$service_dir/hush.service" + + mkdir -p "$service_dir" + chown "$run_user:" "$service_dir" + + cat > "$service_file" < "$rc_file" < "$rc_conf" <&2; } +die() { echo "[uninstall] ERROR: $*" >&2; exit 1; } + +require_root() { + if [[ $EUID -ne 0 ]]; then + die "This script must be run as root (use sudo)." + fi +} + +detect_init() { + if command -v systemctl &>/dev/null && systemctl --version &>/dev/null 2>&1; then + echo "systemd" + elif [[ -f /sbin/openrc-run ]] || [[ -f /usr/sbin/openrc-run ]] || command -v rc-service &>/dev/null; then + echo "openrc" + else + echo "none" + fi +} + +# --------------------------------------------------------------------------- +# Service removal +# --------------------------------------------------------------------------- + +remove_systemd() { + local service_file="$HOME/.config/systemd/user/hush.service" + + info "Stopping and disabling systemd user service..." + + if [[ $EUID -eq 0 && -n "$SUDO_USER" ]]; then + sudo -u "$SUDO_USER" bash -c " + export HOME=$(getent passwd $SUDO_USER | cut -d: -f6) + export XDG_RUNTIME_DIR=/run/user/$(id -u $SUDO_USER) + if systemctl --user is-active --quiet hush.service 2>/dev/null; then + systemctl --user stop hush.service + fi + if systemctl --user is-enabled --quiet hush.service 2>/dev/null; then + systemctl --user disable hush.service + fi + rm -f \$HOME/.config/systemd/user/hush.service + systemctl --user daemon-reload + " + else + if systemctl --user is-active --quiet hush.service 2>/dev/null; then + systemctl --user stop hush.service + fi + if systemctl --user is-enabled --quiet hush.service 2>/dev/null; then + systemctl --user disable hush.service + fi + rm -f "$HOME/.config/systemd/user/hush.service" + systemctl --user daemon-reload + fi + + success "systemd user service removed." +} + +remove_openrc() { + info "Stopping and removing OpenRC service..." + + if rc-service hush status &>/dev/null; then + rc-service hush stop || true + fi + + if rc-update show default 2>/dev/null | grep -q hush; then + rc-update del hush default + fi + + rm -f /etc/init.d/hush + rm -f /etc/conf.d/hush + + success "OpenRC service removed." +} + +# --------------------------------------------------------------------------- +# Runtime artifact cleanup +# --------------------------------------------------------------------------- + +remove_runtime_artifacts() { + local run_user="${SUDO_USER:-$USER}" + local uid + uid=$(id -u "$run_user" 2>/dev/null || echo "") + + if [[ -n "$uid" ]]; then + local sock_path="/run/user/$uid/hush.sock" + if [[ -S "$sock_path" ]]; then + info "Removing socket file: $sock_path" + rm -f "$sock_path" + fi + fi + + # Also clean up /tmp fallback socket + rm -f /tmp/hush.sock + + local data_home + if [[ -n "$SUDO_USER" ]]; then + local user_home + user_home=$(getent passwd "$SUDO_USER" | cut -d: -f6) + data_home="${XDG_DATA_HOME:-$user_home/.local/share}" + else + data_home="${XDG_DATA_HOME:-$HOME/.local/share}" + fi + + local lock_file="$data_home/hush/daemon.lock" + if [[ -f "$lock_file" ]]; then + info "Removing lock file: $lock_file" + rm -f "$lock_file" + rmdir --ignore-fail-on-non-empty "$data_home/hush" 2>/dev/null || true + fi +} + +# --------------------------------------------------------------------------- +# Config removal (optional) +# --------------------------------------------------------------------------- + +remove_config() { + local user_home + if [[ -n "$SUDO_USER" ]]; then + user_home=$(getent passwd "$SUDO_USER" | cut -d: -f6) + else + user_home="$HOME" + fi + + local config_dir="$user_home/.config/hush" + + if [[ -d "$config_dir" ]]; then + echo "" + read -r -p "[uninstall] Remove configuration directory $config_dir? [y/N] " answer + if [[ "$answer" =~ ^[Yy]$ ]]; then + rm -rf "$config_dir" + success "Configuration directory removed." + else + info "Configuration directory kept at $config_dir." + fi + fi +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +main() { + local init_system + init_system=$(detect_init) + + require_root + + # Stop and remove service + case "$init_system" in + systemd) + info "Detected init system: systemd" + remove_systemd + ;; + openrc) + info "Detected init system: OpenRC" + remove_openrc + ;; + none) + warn "Could not detect a supported init system — skipping service removal." + ;; + esac + + # Remove binary + info "Removing binary..." + rm -f "$BIN_DIR/hush" + success "Binary removed." + + # Remove zsh completion + if [[ -f "$COMPLETION_DIR/_hush" ]]; then + info "Removing zsh completion..." + rm -f "$COMPLETION_DIR/_hush" + success "Zsh completion removed." + fi + + # Clean up runtime artifacts + remove_runtime_artifacts + + # Optionally remove user config + remove_config + + echo "" + success "Uninstall complete." + echo "" +} + +main "$@"