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