From 0577640da983281588ecd8abc99caf295290cf23 Mon Sep 17 00:00:00 2001 From: TomiEckert Date: Sat, 28 Feb 2026 15:36:03 +0100 Subject: [PATCH] feat: Introduce `ITranscriptionOrchestrator` and related interfaces, refactoring `DaemonService` and other components to use dependency injection. --- Api/GroqApiClient.cs | 3 +- Audio/AudioRecorder.cs | 28 ++-- Commands/ConfigUpdaterCommand.cs | 5 +- Commands/HistoryCommand.cs | 6 +- Commands/LatencyTestCommand.cs | 2 +- Commands/OnboardCommand.cs | 5 +- Commands/ShowCommand.cs | 2 +- Commands/StatsCommand.cs | 2 +- Configuration/ConfigManager.cs | 19 ++- Core/DaemonService.cs | 152 ++++------------- Core/HistoryManager.cs | 18 ++- Core/Interfaces/ITranscriptionOrchestrator.cs | 11 ++ Core/Interfaces/Interfaces.cs | 62 +++++++ Core/StateTracker.cs | 14 +- Core/TranscriptionOrchestrator.cs | 153 ++++++++++++++++++ IO/ClipboardManager.cs | 15 +- IO/Notifications.cs | 12 +- IO/TextInjector.cs | 22 ++- 18 files changed, 356 insertions(+), 175 deletions(-) create mode 100644 Core/Interfaces/ITranscriptionOrchestrator.cs create mode 100644 Core/Interfaces/Interfaces.cs create mode 100644 Core/TranscriptionOrchestrator.cs diff --git a/Api/GroqApiClient.cs b/Api/GroqApiClient.cs index ba33606..cc95cf1 100644 --- a/Api/GroqApiClient.cs +++ b/Api/GroqApiClient.cs @@ -5,10 +5,11 @@ using System.Text.Json.Serialization; using Toak.Api.Models; using Toak.Serialization; using Toak.Core; +using Toak.Core.Interfaces; namespace Toak.Api; -public class GroqApiClient +public class GroqApiClient : ISpeechClient, ILlmClient { private readonly HttpClient _httpClient; diff --git a/Audio/AudioRecorder.cs b/Audio/AudioRecorder.cs index aad2ac4..7cb9910 100644 --- a/Audio/AudioRecorder.cs +++ b/Audio/AudioRecorder.cs @@ -3,15 +3,25 @@ using System.Diagnostics; using Toak.Core; using Toak.IO; +using Toak.Core.Interfaces; + namespace Toak.Audio; -public static class AudioRecorder +public class AudioRecorder : IAudioRecorder { - private static readonly string WavPath = Path.Combine(Path.GetTempPath(), "toak_recording.wav"); + private readonly string WavPath = Path.Combine(Path.GetTempPath(), "toak_recording.wav"); + private readonly IRecordingStateTracker _stateTracker; + private readonly INotifications _notifications; - public static string GetWavPath() => WavPath; + public AudioRecorder(IRecordingStateTracker stateTracker, INotifications notifications) + { + _stateTracker = stateTracker; + _notifications = notifications; + } - public static void StartRecording() + public string GetWavPath() => WavPath; + + public void StartRecording() { if (File.Exists(WavPath)) { @@ -34,14 +44,14 @@ public static class AudioRecorder var process = Process.Start(pInfo); if (process != null) { - StateTracker.SetRecording(process.Id); - Notifications.Notify("Recording Started"); + _stateTracker.SetRecording(process.Id); + _notifications.Notify("Recording Started"); } } - public static void StopRecording() + public void StopRecording() { - var pid = StateTracker.GetRecordingPid(); + var pid = _stateTracker.GetRecordingPid(); if (pid.HasValue) { Logger.LogDebug($"Found active recording process with PID {pid.Value}. Attempting to stop..."); @@ -69,7 +79,7 @@ public static class AudioRecorder } finally { - StateTracker.ClearRecording(); + _stateTracker.ClearRecording(); } } } diff --git a/Commands/ConfigUpdaterCommand.cs b/Commands/ConfigUpdaterCommand.cs index 3a386e5..3ed962f 100644 --- a/Commands/ConfigUpdaterCommand.cs +++ b/Commands/ConfigUpdaterCommand.cs @@ -9,7 +9,8 @@ public static class ConfigUpdaterCommand public static async Task ExecuteAsync(string key, string val, bool verbose) { Toak.Core.Logger.Verbose = verbose; - var config = ConfigManager.LoadConfig(); + var configManager = new ConfigManager(); + var config = configManager.LoadConfig(); key = key.ToLowerInvariant(); val = val.ToLowerInvariant(); @@ -33,7 +34,7 @@ public static class ConfigUpdaterCommand return; } - ConfigManager.SaveConfig(config); + configManager.SaveConfig(config); AnsiConsole.MarkupLine($"[green]Successfully[/] set {key} to [blue]{val}[/]."); } } diff --git a/Commands/HistoryCommand.cs b/Commands/HistoryCommand.cs index 57f92e9..1b4e08b 100644 --- a/Commands/HistoryCommand.cs +++ b/Commands/HistoryCommand.cs @@ -15,14 +15,16 @@ public static class HistoryCommand { Logger.Verbose = verbose; + var historyManager = new HistoryManager(); + if (shred) { - HistoryManager.Shred(); + historyManager.ClearHistory(); AnsiConsole.MarkupLine("[green]History successfully shredded.[/]"); return; } - var entries = HistoryManager.LoadEntries(); + var entries = historyManager.LoadHistory(); if (entries.Count == 0) { AnsiConsole.MarkupLine("[yellow]No history found.[/]"); diff --git a/Commands/LatencyTestCommand.cs b/Commands/LatencyTestCommand.cs index bd19c29..290bf44 100644 --- a/Commands/LatencyTestCommand.cs +++ b/Commands/LatencyTestCommand.cs @@ -14,7 +14,7 @@ public static class LatencyTestCommand public static async Task ExecuteAsync(bool verbose) { Logger.Verbose = verbose; - var config = ConfigManager.LoadConfig(); + var config = new ConfigManager().LoadConfig(); if (string.IsNullOrWhiteSpace(config.GroqApiKey)) { AnsiConsole.MarkupLine("[red]Groq API Key is not configured.[/] Run 'toak onboard'."); diff --git a/Commands/OnboardCommand.cs b/Commands/OnboardCommand.cs index b0713cb..f488a9a 100644 --- a/Commands/OnboardCommand.cs +++ b/Commands/OnboardCommand.cs @@ -12,7 +12,8 @@ public static class OnboardCommand public static async Task ExecuteAsync(bool verbose) { Toak.Core.Logger.Verbose = verbose; - var config = ConfigManager.LoadConfig(); + var configManager = new ConfigManager(); + var config = configManager.LoadConfig(); AnsiConsole.Write(new FigletText("Toak").Color(Color.Green)); AnsiConsole.MarkupLine("[grey]Welcome to the Toak configuration wizard.[/]"); @@ -71,7 +72,7 @@ public static class OnboardCommand .AddChoices(availableSkills)); } - ConfigManager.SaveConfig(config); + configManager.SaveConfig(config); AnsiConsole.MarkupLine("\n[bold green]Configuration saved successfully![/]"); } diff --git a/Commands/ShowCommand.cs b/Commands/ShowCommand.cs index 84c8412..0a124ab 100644 --- a/Commands/ShowCommand.cs +++ b/Commands/ShowCommand.cs @@ -9,7 +9,7 @@ public static class ShowCommand public static async Task ExecuteAsync(bool verbose) { Toak.Core.Logger.Verbose = verbose; - var config = ConfigManager.LoadConfig(); + var config = new ConfigManager().LoadConfig(); var table = new Table(); table.AddColumn("Setting"); diff --git a/Commands/StatsCommand.cs b/Commands/StatsCommand.cs index 49d4887..dd2c2f4 100644 --- a/Commands/StatsCommand.cs +++ b/Commands/StatsCommand.cs @@ -13,7 +13,7 @@ public static class StatsCommand { Logger.Verbose = verbose; - var entries = HistoryManager.LoadEntries(); + var entries = new HistoryManager().LoadHistory(); if (entries.Count == 0) { AnsiConsole.MarkupLine("[yellow]No history found. Cannot generate statistics.[/]"); diff --git a/Configuration/ConfigManager.cs b/Configuration/ConfigManager.cs index 69be953..c0da52e 100644 --- a/Configuration/ConfigManager.cs +++ b/Configuration/ConfigManager.cs @@ -2,14 +2,21 @@ using System.Text.Json; using System.Text.Json.Serialization; using Toak.Serialization; +using Toak.Core.Interfaces; namespace Toak.Configuration; -public static class ConfigManager -{ - private static readonly string ConfigDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "toak"); - private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json"); - public static ToakConfig LoadConfig() +public class ConfigManager : IConfigProvider +{ + private readonly string ConfigDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "toak"); + private readonly string ConfigPath; + + public ConfigManager() + { + ConfigPath = Path.Combine(ConfigDir, "config.json"); + } + + public ToakConfig LoadConfig() { if (!File.Exists(ConfigPath)) { @@ -27,7 +34,7 @@ public static class ConfigManager } } - public static void SaveConfig(ToakConfig config) + public void SaveConfig(ToakConfig config) { if (!Directory.Exists(ConfigDir)) { diff --git a/Core/DaemonService.cs b/Core/DaemonService.cs index 1350b0d..01824b9 100644 --- a/Core/DaemonService.cs +++ b/Core/DaemonService.cs @@ -2,19 +2,17 @@ using System; using System.Diagnostics; using System.IO; using System.Net.Sockets; -using System.Threading; using System.Threading.Tasks; -using Toak.Audio; using Toak.Configuration; using Toak.Api; +using Toak.Core.Interfaces; +using Toak.Audio; using Toak.IO; namespace Toak.Core; public static class DaemonService { - private static GroqApiClient? _groqClient; - public static string GetSocketPath() { var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); @@ -49,14 +47,29 @@ public static class DaemonService try { File.Delete(socketPath); } catch { } } - var config = ConfigManager.LoadConfig(); + var configManager = new ConfigManager(); + var config = configManager.LoadConfig(); if (string.IsNullOrWhiteSpace(config.GroqApiKey)) { Console.WriteLine("Groq API Key is not configured. Run 'toak onboard'."); return; } - _groqClient = new GroqApiClient(config.GroqApiKey); + var stateTracker = new StateTracker(); + var notifications = new Notifications(); + + var groqClient = new GroqApiClient(config.GroqApiKey); + var orchestrator = new TranscriptionOrchestrator( + groqClient, + groqClient, + configManager, + new AudioRecorder(stateTracker, notifications), + notifications, + new TextInjector(notifications), + new HistoryManager(), + new ClipboardManager(notifications), + stateTracker + ); using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); var endPoint = new UnixDomainSocketEndPoint(socketPath); @@ -71,7 +84,7 @@ public static class DaemonService while (true) { var client = await socket.AcceptAsync(); - _ = Task.Run(() => HandleClientAsync(client)); + _ = Task.Run(() => HandleClientAsync(client, orchestrator, stateTracker)); } } catch (Exception ex) @@ -87,7 +100,7 @@ public static class DaemonService } } - private static async Task HandleClientAsync(Socket client) + private static async Task HandleClientAsync(Socket client, ITranscriptionOrchestrator orchestrator, IRecordingStateTracker stateTracker) { try { @@ -101,22 +114,22 @@ public static class DaemonService if (cmd == 1) // START { - await ProcessStartRecordingAsync(); + await orchestrator.ProcessStartRecordingAsync(); } else if (cmd == 2) // STOP { - await ProcessStopRecordingAsync(client, pipeToStdout, copyToClipboard); + await orchestrator.ProcessStopRecordingAsync(client, pipeToStdout, copyToClipboard); } else if (cmd == 3) // ABORT { - ProcessAbortAsync(); + orchestrator.ProcessAbortAsync(); } else if (cmd == 4) // TOGGLE { - if (StateTracker.IsRecording()) - await ProcessStopRecordingAsync(client, pipeToStdout, copyToClipboard); + if (stateTracker.IsRecording()) + await orchestrator.ProcessStopRecordingAsync(client, pipeToStdout, copyToClipboard); else - await ProcessStartRecordingAsync(); + await orchestrator.ProcessStartRecordingAsync(); } } } @@ -129,114 +142,5 @@ public static class DaemonService client.Close(); } } - - private static async Task ProcessStartRecordingAsync() - { - if (StateTracker.IsRecording()) return; - - Logger.LogDebug("Received START command"); - var config = ConfigManager.LoadConfig(); - Notifications.PlaySound(config.StartSoundPath); - AudioRecorder.StartRecording(); - } - - private static async Task ProcessStopRecordingAsync(Socket client, bool pipeToStdout, bool copyToClipboard) - { - if (!StateTracker.IsRecording()) return; - - Logger.LogDebug("Received STOP command"); - var config = ConfigManager.LoadConfig(); - Notifications.PlaySound(config.StopSoundPath); - Notifications.Notify("Toak", "Transcribing..."); - - AudioRecorder.StopRecording(); - - var wavPath = AudioRecorder.GetWavPath(); - if (!File.Exists(wavPath) || new FileInfo(wavPath).Length == 0) - { - Notifications.Notify("Toak", "No audio recorded."); - return; - } - - try - { - var stopWatch = Stopwatch.StartNew(); - - Logger.LogDebug($"Starting STT via Whisper for {wavPath}..."); - var transcript = await _groqClient!.TranscribeAsync(wavPath, config.WhisperLanguage, config.WhisperModel); - - if (string.IsNullOrWhiteSpace(transcript)) - { - Notifications.Notify("Toak", "No speech detected."); - return; - } - - // LLM Refinement - var detectedSkill = Toak.Core.Skills.SkillRegistry.DetectSkill(transcript, config.ActiveSkills); - string systemPrompt = detectedSkill != null ? detectedSkill.GetSystemPrompt(transcript) : PromptBuilder.BuildPrompt(config); - bool isExecutionSkill = detectedSkill != null && detectedSkill.HandlesExecution; - - if (isExecutionSkill) - { - var finalText = await _groqClient.RefineTextAsync(transcript, systemPrompt, config.LlmModel); - if (!string.IsNullOrWhiteSpace(finalText)) - { - detectedSkill!.Execute(finalText); - stopWatch.Stop(); - HistoryManager.SaveEntry(transcript, finalText, detectedSkill.Name, stopWatch.ElapsedMilliseconds); - Notifications.Notify("Toak", $"Skill executed in {stopWatch.ElapsedMilliseconds}ms"); - } - } - else - { - Logger.LogDebug("Starting LLM text refinement (streaming)..."); - var tokenStream = _groqClient.RefineTextStreamAsync(transcript, systemPrompt, config.LlmModel); - - if (pipeToStdout || copyToClipboard) - { - string fullText = ""; - await foreach (var token in tokenStream) - { - fullText += token; - if (pipeToStdout) - { - await client.SendAsync(System.Text.Encoding.UTF8.GetBytes(token), SocketFlags.None); - } - } - stopWatch.Stop(); - if (copyToClipboard) - { - ClipboardManager.Copy(fullText); - Notifications.Notify("Toak", $"Copied to clipboard in {stopWatch.ElapsedMilliseconds}ms"); - } - HistoryManager.SaveEntry(transcript, fullText, detectedSkill?.Name, stopWatch.ElapsedMilliseconds); - } - else - { - string fullText = await TextInjector.InjectStreamAsync(tokenStream, config.TypingBackend); - stopWatch.Stop(); - HistoryManager.SaveEntry(transcript, fullText, detectedSkill?.Name, stopWatch.ElapsedMilliseconds); - Notifications.Notify("Toak", $"Done in {stopWatch.ElapsedMilliseconds}ms"); - } - } - } - catch (Exception ex) - { - Notifications.Notify("Toak Error", ex.Message); - Logger.LogDebug($"Error during processing: {ex.Message}"); - } - finally - { - if (File.Exists(wavPath)) File.Delete(wavPath); - } - } - - private static void ProcessAbortAsync() - { - Logger.LogDebug("Received ABORT command"); - AudioRecorder.StopRecording(); - var wavPath = AudioRecorder.GetWavPath(); - if (File.Exists(wavPath)) File.Delete(wavPath); - Notifications.Notify("Toak", "Recording Aborted."); - } } + diff --git a/Core/HistoryManager.cs b/Core/HistoryManager.cs index c273ed1..db1dafe 100644 --- a/Core/HistoryManager.cs +++ b/Core/HistoryManager.cs @@ -4,15 +4,21 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; using Toak.Serialization; +using Toak.Core.Interfaces; namespace Toak.Core; -public static class HistoryManager +public class HistoryManager : IHistoryManager { - private static readonly string HistoryDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "toak"); - private static readonly string HistoryFile = Path.Combine(HistoryDir, "history.jsonl"); + private readonly string HistoryDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "toak"); + private readonly string HistoryFile; - public static void SaveEntry(string rawTranscript, string refinedText, string? skillName, long durationMs) + public HistoryManager() + { + HistoryFile = Path.Combine(HistoryDir, "history.jsonl"); + } + + public void SaveEntry(string rawTranscript, string refinedText, string? skillName, long durationMs) { try { @@ -44,7 +50,7 @@ public static class HistoryManager } } - public static List LoadEntries() + public List LoadHistory() { var entries = new List(); if (!File.Exists(HistoryFile)) return entries; @@ -85,7 +91,7 @@ public static class HistoryManager return entries; } - public static void Shred() + public void ClearHistory() { if (File.Exists(HistoryFile)) { diff --git a/Core/Interfaces/ITranscriptionOrchestrator.cs b/Core/Interfaces/ITranscriptionOrchestrator.cs new file mode 100644 index 0000000..f4a2e29 --- /dev/null +++ b/Core/Interfaces/ITranscriptionOrchestrator.cs @@ -0,0 +1,11 @@ +using System.Net.Sockets; +using System.Threading.Tasks; + +namespace Toak.Core.Interfaces; + +public interface ITranscriptionOrchestrator +{ + Task ProcessStartRecordingAsync(); + Task ProcessStopRecordingAsync(Socket client, bool pipeToStdout, bool copyToClipboard); + void ProcessAbortAsync(); +} diff --git a/Core/Interfaces/Interfaces.cs b/Core/Interfaces/Interfaces.cs new file mode 100644 index 0000000..ad0b818 --- /dev/null +++ b/Core/Interfaces/Interfaces.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Toak.Configuration; + +namespace Toak.Core.Interfaces; + +public interface IConfigProvider +{ + ToakConfig LoadConfig(); + void SaveConfig(ToakConfig config); +} + +public interface ISpeechClient +{ + Task TranscribeAsync(string filePath, string language = "", string model = "whisper-large-v3-turbo"); +} + +public interface ILlmClient +{ + Task RefineTextAsync(string rawTranscript, string systemPrompt, string model = "openai/gpt-oss-20b"); + IAsyncEnumerable RefineTextStreamAsync(string rawTranscript, string systemPrompt, string model = "openai/gpt-oss-20b"); +} + +public interface IAudioRecorder +{ + string GetWavPath(); + void StartRecording(); + void StopRecording(); +} + +public interface INotifications +{ + void Notify(string title, string message = ""); + void PlaySound(string soundPath); +} + +public interface ITextInjector +{ + Task InjectStreamAsync(IAsyncEnumerable textStream, string backend = "xdotool"); + Task InjectTextAsync(string text, string backend = "xdotool"); +} + +public interface IHistoryManager +{ + void SaveEntry(string rawText, string finalText, string? skillUsed, long timeTakenMs); + List LoadHistory(); + void ClearHistory(); +} + +public interface IClipboardManager +{ + void Copy(string text); +} + +public interface IRecordingStateTracker +{ + int? GetRecordingPid(); + void SetRecording(int pid); + void ClearRecording(); + bool IsRecording(); +} diff --git a/Core/StateTracker.cs b/Core/StateTracker.cs index 8cc55b3..b6e71c8 100644 --- a/Core/StateTracker.cs +++ b/Core/StateTracker.cs @@ -1,21 +1,23 @@ +using Toak.Core.Interfaces; + namespace Toak.Core; -public static class StateTracker +public class StateTracker : IRecordingStateTracker { - private static readonly string StateFilePath = Path.Combine(Path.GetTempPath(), "toak_state.pid"); + private readonly string StateFilePath = Path.Combine(Path.GetTempPath(), "toak_state.pid"); - public static bool IsRecording() + public bool IsRecording() { return File.Exists(StateFilePath); } - public static void SetRecording(int ffmpegPid) + public void SetRecording(int ffmpegPid) { Logger.LogDebug($"Setting recording state with PID {ffmpegPid}"); File.WriteAllText(StateFilePath, ffmpegPid.ToString()); } - public static int? GetRecordingPid() + public int? GetRecordingPid() { if (File.Exists(StateFilePath)) { @@ -29,7 +31,7 @@ public static class StateTracker return null; } - public static void ClearRecording() + public void ClearRecording() { if (File.Exists(StateFilePath)) { diff --git a/Core/TranscriptionOrchestrator.cs b/Core/TranscriptionOrchestrator.cs new file mode 100644 index 0000000..04f952f --- /dev/null +++ b/Core/TranscriptionOrchestrator.cs @@ -0,0 +1,153 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Sockets; +using System.Threading.Tasks; +using Toak.Core.Interfaces; +using Toak.Configuration; + +namespace Toak.Core; + +public class TranscriptionOrchestrator : ITranscriptionOrchestrator +{ + private readonly ISpeechClient _speechClient; + private readonly ILlmClient _llmClient; + private readonly IConfigProvider _configProvider; + private readonly IAudioRecorder _audioRecorder; + private readonly INotifications _notifications; + private readonly ITextInjector _textInjector; + private readonly IHistoryManager _historyManager; + private readonly IClipboardManager _clipboardManager; + private readonly IRecordingStateTracker _stateTracker; + + public TranscriptionOrchestrator( + ISpeechClient speechClient, + ILlmClient llmClient, + IConfigProvider configProvider, + IAudioRecorder audioRecorder, + INotifications notifications, + ITextInjector textInjector, + IHistoryManager historyManager, + IClipboardManager clipboardManager, + IRecordingStateTracker stateTracker) + { + _speechClient = speechClient; + _llmClient = llmClient; + _configProvider = configProvider; + _audioRecorder = audioRecorder; + _notifications = notifications; + _textInjector = textInjector; + _historyManager = historyManager; + _clipboardManager = clipboardManager; + _stateTracker = stateTracker; + } + + public async Task ProcessStartRecordingAsync() + { + if (_stateTracker.IsRecording()) return; + + Logger.LogDebug("Received START command"); + var config = _configProvider.LoadConfig(); + _notifications.PlaySound(config.StartSoundPath); + _audioRecorder.StartRecording(); + } + + public async Task ProcessStopRecordingAsync(Socket client, bool pipeToStdout, bool copyToClipboard) + { + if (!_stateTracker.IsRecording()) return; + + Logger.LogDebug("Received STOP command"); + var config = _configProvider.LoadConfig(); + _notifications.PlaySound(config.StopSoundPath); + _notifications.Notify("Toak", "Transcribing..."); + + _audioRecorder.StopRecording(); + + var wavPath = _audioRecorder.GetWavPath(); + if (!File.Exists(wavPath) || new FileInfo(wavPath).Length == 0) + { + _notifications.Notify("Toak", "No audio recorded."); + return; + } + + try + { + var stopWatch = Stopwatch.StartNew(); + + Logger.LogDebug($"Starting STT via Whisper for {wavPath}..."); + var transcript = await _speechClient.TranscribeAsync(wavPath, config.WhisperLanguage, config.WhisperModel); + + if (string.IsNullOrWhiteSpace(transcript)) + { + _notifications.Notify("Toak", "No speech detected."); + return; + } + + var detectedSkill = Toak.Core.Skills.SkillRegistry.DetectSkill(transcript, config.ActiveSkills); + string systemPrompt = detectedSkill != null ? detectedSkill.GetSystemPrompt(transcript) : PromptBuilder.BuildPrompt(config); + bool isExecutionSkill = detectedSkill != null && detectedSkill.HandlesExecution; + + if (isExecutionSkill) + { + var finalText = await _llmClient.RefineTextAsync(transcript, systemPrompt, config.LlmModel); + if (!string.IsNullOrWhiteSpace(finalText)) + { + detectedSkill!.Execute(finalText); + stopWatch.Stop(); + _historyManager.SaveEntry(transcript, finalText, detectedSkill.Name, stopWatch.ElapsedMilliseconds); + _notifications.Notify("Toak", $"Skill executed in {stopWatch.ElapsedMilliseconds}ms"); + } + } + else + { + Logger.LogDebug("Starting LLM text refinement (streaming)..."); + var tokenStream = _llmClient.RefineTextStreamAsync(transcript, systemPrompt, config.LlmModel); + + if (pipeToStdout || copyToClipboard) + { + string fullText = ""; + await foreach (var token in tokenStream) + { + fullText += token; + if (pipeToStdout) + { + await client.SendAsync(System.Text.Encoding.UTF8.GetBytes(token), SocketFlags.None); + } + } + stopWatch.Stop(); + if (copyToClipboard) + { + _clipboardManager.Copy(fullText); + _notifications.Notify("Toak", $"Copied to clipboard in {stopWatch.ElapsedMilliseconds}ms"); + } + _historyManager.SaveEntry(transcript, fullText, detectedSkill?.Name, stopWatch.ElapsedMilliseconds); + } + else + { + string fullText = await _textInjector.InjectStreamAsync(tokenStream, config.TypingBackend); + stopWatch.Stop(); + _historyManager.SaveEntry(transcript, fullText, detectedSkill?.Name, stopWatch.ElapsedMilliseconds); + _notifications.Notify("Toak", $"Done in {stopWatch.ElapsedMilliseconds}ms"); + } + } + } + catch (Exception ex) + { + _notifications.Notify("Toak Error", ex.Message); + Logger.LogDebug($"Error during processing: {ex.Message}"); + } + finally + { + if (File.Exists(wavPath)) File.Delete(wavPath); + } + } + + public void ProcessAbortAsync() + { + Logger.LogDebug("Received ABORT command"); + _audioRecorder.StopRecording(); + var wavPath = _audioRecorder.GetWavPath(); + if (File.Exists(wavPath)) File.Delete(wavPath); + _notifications.Notify("Toak", "Recording Aborted."); + } +} diff --git a/IO/ClipboardManager.cs b/IO/ClipboardManager.cs index 1bdb873..34c8fd5 100644 --- a/IO/ClipboardManager.cs +++ b/IO/ClipboardManager.cs @@ -1,10 +1,19 @@ using System.Diagnostics; +using Toak.Core.Interfaces; + namespace Toak.IO; -public static class ClipboardManager +public class ClipboardManager : IClipboardManager { - public static void Copy(string text) + private readonly INotifications _notifications; + + public ClipboardManager(INotifications notifications) + { + _notifications = notifications; + } + + public void Copy(string text) { if (string.IsNullOrWhiteSpace(text)) return; try @@ -47,7 +56,7 @@ public static class ClipboardManager catch (Exception ex) { Console.WriteLine($"[ClipboardManager] Error copying text: {ex.Message}"); - Notifications.Notify("Clipboard Error", "Could not copy text to clipboard."); + _notifications.Notify("Clipboard Error", "Could not copy text to clipboard."); } } } diff --git a/IO/Notifications.cs b/IO/Notifications.cs index 32f0573..2a39b1c 100644 --- a/IO/Notifications.cs +++ b/IO/Notifications.cs @@ -1,12 +1,14 @@ using System.Diagnostics; +using Toak.Core.Interfaces; + namespace Toak.IO; -public static class Notifications +public class Notifications : INotifications { - private static bool _notifySendAvailable = true; + private bool _notifySendAvailable = true; - public static void Notify(string summary, string body = "") + public void Notify(string summary, string body = "") { if (!_notifySendAvailable) return; @@ -32,9 +34,9 @@ public static class Notifications } } - private static bool _paplayAvailable = true; + private bool _paplayAvailable = true; - public static void PlaySound(string soundPath) + public void PlaySound(string soundPath) { if (!_paplayAvailable || string.IsNullOrWhiteSpace(soundPath)) return; try diff --git a/IO/TextInjector.cs b/IO/TextInjector.cs index 32144a3..3d4815c 100644 --- a/IO/TextInjector.cs +++ b/IO/TextInjector.cs @@ -2,14 +2,23 @@ using System.Diagnostics; using Toak.Core; +using Toak.Core.Interfaces; + namespace Toak.IO; -public static class TextInjector +public class TextInjector : ITextInjector { - public static void Inject(string text, string backend) + private readonly INotifications _notifications; + + public TextInjector(INotifications notifications) + { + _notifications = notifications; + } + + public Task InjectTextAsync(string text, string backend = "xdotool") { Logger.LogDebug($"Injecting text: '{text}' with {backend}"); - if (string.IsNullOrWhiteSpace(text)) return; + if (string.IsNullOrWhiteSpace(text)) return Task.CompletedTask; try { @@ -42,11 +51,12 @@ public static class TextInjector catch (Exception ex) { Console.WriteLine($"[TextInjector] Error injecting text: {ex.Message}"); - Notifications.Notify("Injection Error", "Could not type text into window."); + _notifications.Notify("Injection Error", "Could not type text into window."); } + return Task.CompletedTask; } - public static async Task InjectStreamAsync(IAsyncEnumerable tokenStream, string backend) + public async Task InjectStreamAsync(IAsyncEnumerable tokenStream, string backend) { string fullText = string.Empty; try @@ -97,7 +107,7 @@ public static class TextInjector catch (Exception ex) { Console.WriteLine($"[TextInjector] Error injecting text stream: {ex.Message}"); - Notifications.Notify("Injection Error", "Could not type text stream into window."); + _notifications.Notify("Injection Error", "Could not type text stream into window."); } return fullText; }