initial commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user