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
+6
View File
@@ -0,0 +1,6 @@
*/bin
*/obj
.vs
.vscode
.idea
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<SelfContained>true</SelfContained>
</PropertyGroup>
</Project>
+41
View File
@@ -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;
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Hush.Audio;
public interface IAudioRecorder
{
Task StartRecording(string path);
Task StopRecording();
bool IsRecording { get; }
}
+49
View File
@@ -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;
}
}
+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;
}
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<SelfContained>true</SelfContained>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Tomlyn" Version="0.19.0" />
</ItemGroup>
</Project>
+84
View File
@@ -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<TomlTable>(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);
}
}
+26
View File
@@ -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;
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<SelfContained>true</SelfContained>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Hush.Config\Hush.Config.csproj" />
<ProjectReference Include="..\Hush.Audio\Hush.Audio.csproj" />
<ProjectReference Include="..\Hush.Providers\Hush.Providers.csproj" />
<ProjectReference Include="..\Hush.Input\Hush.Input.csproj" />
</ItemGroup>
</Project>
+25
View File
@@ -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;
+217
View File
@@ -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");
}
}
+314
View File
@@ -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<string> 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<string> 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<LatencyResult> 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<string> 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();
}
}
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<SelfContained>true</SelfContained>
</PropertyGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
namespace Hush.Input;
public interface ITextInput
{
Task TypeString(string text);
}
+24
View File
@@ -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();
}
}
+24
View File
@@ -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();
}
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,19 @@
namespace Hush.Providers.Interfaces;
/// <summary>
/// Interface for audio-to-text transcription functionality.
/// </summary>
public interface IAudioToTextProvider
{
/// <summary>
/// Transcribes audio from a stream to text.
/// </summary>
/// <param name="audioStream">The audio stream to transcribe</param>
/// <param name="modelName">The model name to use for transcription (e.g., whisper-large-v3)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The transcribed text</returns>
Task<string> TranscribeAsync(
Stream audioStream,
string modelName,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,31 @@
namespace Hush.Providers.Interfaces;
/// <summary>
/// Interface for text generation with both synchronous and streaming capabilities.
/// </summary>
public interface ITextStreamingProvider
{
/// <summary>
/// Generates text completion for a given prompt.
/// </summary>
/// <param name="prompt">The input prompt</param>
/// <param name="modelName">The model name to use (e.g., llama-3.3-70b-versatile)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The generated text</returns>
Task<string> CompleteTextAsync(
string prompt,
string modelName,
CancellationToken cancellationToken = default);
/// <summary>
/// Streams text generation for a given prompt.
/// </summary>
/// <param name="prompt">The input prompt</param>
/// <param name="modelName">The model name to use (e.g., llama-3.3-70b-versatile)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Async enumerable of text chunks</returns>
IAsyncEnumerable<string> StreamTextAsync(
string prompt,
string modelName,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,75 @@
using System.Text.Json.Serialization;
namespace Hush.Providers.Models.Request;
/// <summary>
/// Request model for Groq chat completion API.
/// </summary>
public record ChatCompletionRequest
{
/// <summary>
/// A list of messages comprising the conversation so far.
/// </summary>
[JsonPropertyName("messages")]
public required List<Message> Messages { get; init; }
/// <summary>
/// ID of the model to use.
/// </summary>
[JsonPropertyName("model")]
public required string Model { get; init; }
/// <summary>
/// Whether to stream the response.
/// </summary>
[JsonPropertyName("stream")]
public bool Stream { get; init; } = false;
/// <summary>
/// Sampling temperature (0 to 2).
/// </summary>
[JsonPropertyName("temperature")]
public float? Temperature { get; init; } = 1.0f;
/// <summary>
/// Nucleus sampling cutoff (0 to 1).
/// </summary>
[JsonPropertyName("top_p")]
public float? TopP { get; init; } = 1.0f;
/// <summary>
/// Maximum number of tokens to generate.
/// </summary>
[JsonPropertyName("max_completion_tokens")]
public int? MaxCompletionTokens { get; init; }
/// <summary>
/// Up to 4 sequences where the API stops generating tokens.
/// </summary>
[JsonPropertyName("stop")]
public string[]? Stop { get; init; }
/// <summary>
/// Unique identifier representing your end-user.
/// </summary>
[JsonPropertyName("user")]
public string? User { get; init; }
}
/// <summary>
/// A message in the chat conversation.
/// </summary>
public record Message
{
/// <summary>
/// The role of the message author.
/// </summary>
[JsonPropertyName("role")]
public required string Role { get; init; }
/// <summary>
/// The content of the message.
/// </summary>
[JsonPropertyName("content")]
public required string Content { get; init; }
}
@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace Hush.Providers.Models.Request;
/// <summary>
/// Request model for Groq audio transcription API.
/// </summary>
public record TranscriptionRequest
{
/// <summary>
/// The model to use for transcription.
/// </summary>
[JsonPropertyName("model")]
public required string Model { get; init; }
/// <summary>
/// The language of the audio (ISO-639-1 format).
/// </summary>
[JsonPropertyName("language")]
public string? Language { get; init; }
/// <summary>
/// Text to guide the model's style or context.
/// </summary>
[JsonPropertyName("prompt")]
public string? Prompt { get; init; }
/// <summary>
/// Response format (json, text, verbose_json).
/// </summary>
[JsonPropertyName("response_format")]
public string? ResponseFormat { get; init; } = "json";
/// <summary>
/// Sampling temperature (0 to 1).
/// </summary>
[JsonPropertyName("temperature")]
public float? Temperature { get; init; } = 0.0f;
}
@@ -0,0 +1,153 @@
using System.Text.Json.Serialization;
namespace Hush.Providers.Models.Response;
/// <summary>
/// Response model for Groq chat completion API.
/// </summary>
public record ChatCompletionResponse
{
/// <summary>
/// Unique identifier for the completion.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Object type, always "chat.completion".
/// </summary>
[JsonPropertyName("object")]
public required string Object { get; init; }
/// <summary>
/// Unix timestamp of creation.
/// </summary>
[JsonPropertyName("created")]
public required long Created { get; init; }
/// <summary>
/// Model used.
/// </summary>
[JsonPropertyName("model")]
public required string Model { get; init; }
/// <summary>
/// List of completion choices.
/// </summary>
[JsonPropertyName("choices")]
public required List<Choice> Choices { get; init; }
/// <summary>
/// Usage statistics.
/// </summary>
[JsonPropertyName("usage")]
public required Usage Usage { get; init; }
/// <summary>
/// Groq-specific metadata.
/// </summary>
[JsonPropertyName("x_groq")]
public required GroqMetadata XGroq { get; init; }
}
/// <summary>
/// A completion choice.
/// </summary>
public record Choice
{
/// <summary>
/// Index of the choice.
/// </summary>
[JsonPropertyName("index")]
public required int Index { get; init; }
/// <summary>
/// The message content.
/// </summary>
[JsonPropertyName("message")]
public required Message Message { get; init; }
/// <summary>
/// Reason the model stopped generating tokens.
/// </summary>
[JsonPropertyName("finish_reason")]
public required string FinishReason { get; init; }
}
/// <summary>
/// A message in the response.
/// </summary>
public record Message
{
/// <summary>
/// The role of the message author.
/// </summary>
[JsonPropertyName("role")]
public required string Role { get; init; }
/// <summary>
/// The content of the message.
/// </summary>
[JsonPropertyName("content")]
public required string Content { get; init; }
}
/// <summary>
/// Usage statistics for the completion.
/// </summary>
public record Usage
{
/// <summary>
/// Time spent in queue.
/// </summary>
[JsonPropertyName("queue_time")]
public double? QueueTime { get; init; }
/// <summary>
/// Number of tokens in the prompt.
/// </summary>
[JsonPropertyName("prompt_tokens")]
public required int PromptTokens { get; init; }
/// <summary>
/// Time spent processing the prompt.
/// </summary>
[JsonPropertyName("prompt_time")]
public double? PromptTime { get; init; }
/// <summary>
/// Number of tokens in the completion.
/// </summary>
[JsonPropertyName("completion_tokens")]
public required int CompletionTokens { get; init; }
/// <summary>
/// Time spent generating the completion.
/// </summary>
[JsonPropertyName("completion_time")]
public double? CompletionTime { get; init; }
/// <summary>
/// Total number of tokens.
/// </summary>
[JsonPropertyName("total_tokens")]
public required int TotalTokens { get; init; }
/// <summary>
/// Total time for the request.
/// </summary>
[JsonPropertyName("total_time")]
public double? TotalTime { get; init; }
}
/// <summary>
/// Groq-specific metadata.
/// </summary>
public record GroqMetadata
{
/// <summary>
/// Request ID.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
}
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Hush.Providers.Models.Response;
/// <summary>
/// Response model for Groq audio transcription API.
/// </summary>
public record TranscriptionResponse
{
/// <summary>
/// The transcribed text.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
/// <summary>
/// Groq-specific metadata.
/// </summary>
[JsonPropertyName("x_groq")]
public required GroqMetadata XGroq { get; init; }
}
@@ -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;
/// <summary>
/// Implementation of LLM provider for Groq API.
/// </summary>
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;
/// <summary>
/// Initializes a new instance of the GroqProvider class.
/// </summary>
/// <param name="apiKey">The Groq API key</param>
/// <param name="httpClient">Optional HttpClient instance (for testing)</param>
public GroqProvider(string apiKey, HttpClient? httpClient = null)
{
_apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey));
_httpClient = httpClient ?? new HttpClient();
}
/// <inheritdoc />
public async Task<string> 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;
}
/// <inheritdoc />
public async Task<string> 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<Hush.Providers.Models.Request.Message> { 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;
}
/// <inheritdoc />
public async IAsyncEnumerable<string> 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<Hush.Providers.Models.Request.Message> { 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;
}
}
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace Hush.Providers.Serialization;
/// <summary>
/// Source generation context for JSON serialization.
/// </summary>
[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
{
}
+8
View File
@@ -0,0 +1,8 @@
<Solution>
<Project Path="Hush.Audio/Hush.Audio.csproj" />
<Project Path="Hush.Cli/Hush.Cli.csproj" />
<Project Path="Hush.Config/Hush.Config.csproj" />
<Project Path="Hush.Daemon/Hush.Daemon.csproj" />
<Project Path="Hush.Input/Hush.Input.csproj" />
<Project Path="Hush.Providers/Hush.Providers.csproj" />
</Solution>
Executable
+14
View File
@@ -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"
+38
View File
@@ -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 "$@"
Executable
+211
View File
@@ -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" <<EOF
[Unit]
Description=Hush speech-to-text daemon
After=graphical-session.target pipewire.service
[Service]
ExecStart=$BIN_DIR/hush daemon
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
EOF
chown "$run_user:" "$service_file"
sudo -u "$run_user" \
XDG_RUNTIME_DIR="/run/user/$uid" \
HOME="$user_home" \
systemctl --user daemon-reload
sudo -u "$run_user" \
XDG_RUNTIME_DIR="/run/user/$uid" \
HOME="$user_home" \
systemctl --user enable --now hush.service
success "systemd user service enabled and started."
}
# ---------------------------------------------------------------------------
# OpenRC service
# ---------------------------------------------------------------------------
install_openrc() {
local rc_file="/etc/init.d/hush"
local rc_conf="/etc/conf.d/hush"
info "Installing OpenRC service..."
# Determine the user to run the daemon as
local run_user="${SUDO_USER:-$(logname 2>/dev/null || echo "$USER")}"
cat > "$rc_file" <<EOF
#!/sbin/openrc-run
name="hush"
description="Hush speech-to-text daemon"
command="$BIN_DIR/hush"
command_args="daemon"
command_user="${run_user}"
# XDG_RUNTIME_DIR is required by the daemon for the Unix socket
export XDG_RUNTIME_DIR="/run/user/\$(id -u $run_user)"
pidfile="/run/\${RC_SVCNAME}.pid"
command_background=true
depend() {
after graphical
use logger
}
EOF
chmod 755 "$rc_file"
cat > "$rc_conf" <<EOF
# Configuration file for the hush OpenRC service.
# Run 'hush setup' to configure Hush itself.
EOF
rc-update add hush default
rc-service hush start
success "OpenRC service installed, added to default runlevel, and started."
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
local init_system
init_system=$(detect_init)
# Build if binary is missing
if [[ ! -f "$CLI_BIN" ]]; then
build
else
info "Binary already built, skipping build step."
info " (Delete the publish directory and re-run to force a rebuild.)"
fi
require_root
install_binary
install_completion
case "$init_system" in
systemd)
info "Detected init system: systemd"
install_systemd
;;
openrc)
info "Detected init system: OpenRC"
install_openrc
;;
none)
warn "Could not detect a supported init system (systemd or OpenRC)."
warn "Start the daemon manually with: hush daemon"
;;
esac
echo ""
success "Installation complete!"
echo ""
echo " Run 'hush setup' to configure your API keys and preferences."
echo " Run 'hush status' to check daemon status."
echo ""
}
main "$@"
Executable
+198
View File
@@ -0,0 +1,198 @@
#!/bin/bash
set -e
INSTALL_PREFIX="${INSTALL_PREFIX:-/usr}"
BIN_DIR="$INSTALL_PREFIX/bin"
COMPLETION_DIR="$INSTALL_PREFIX/share/zsh/site-functions"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
info() { echo "[uninstall] $*"; }
success() { echo "[uninstall] $*"; }
warn() { echo "[uninstall] WARNING: $*" >&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 "$@"