feat: Introduce ITranscriptionOrchestrator and related interfaces, refactoring DaemonService and other components to use dependency injection.
This commit is contained in:
@@ -5,10 +5,11 @@ using System.Text.Json.Serialization;
|
|||||||
using Toak.Api.Models;
|
using Toak.Api.Models;
|
||||||
using Toak.Serialization;
|
using Toak.Serialization;
|
||||||
using Toak.Core;
|
using Toak.Core;
|
||||||
|
using Toak.Core.Interfaces;
|
||||||
|
|
||||||
namespace Toak.Api;
|
namespace Toak.Api;
|
||||||
|
|
||||||
public class GroqApiClient
|
public class GroqApiClient : ISpeechClient, ILlmClient
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,25 @@ using System.Diagnostics;
|
|||||||
using Toak.Core;
|
using Toak.Core;
|
||||||
using Toak.IO;
|
using Toak.IO;
|
||||||
|
|
||||||
|
using Toak.Core.Interfaces;
|
||||||
|
|
||||||
namespace Toak.Audio;
|
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))
|
if (File.Exists(WavPath))
|
||||||
{
|
{
|
||||||
@@ -34,14 +44,14 @@ public static class AudioRecorder
|
|||||||
var process = Process.Start(pInfo);
|
var process = Process.Start(pInfo);
|
||||||
if (process != null)
|
if (process != null)
|
||||||
{
|
{
|
||||||
StateTracker.SetRecording(process.Id);
|
_stateTracker.SetRecording(process.Id);
|
||||||
Notifications.Notify("Recording Started");
|
_notifications.Notify("Recording Started");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void StopRecording()
|
public void StopRecording()
|
||||||
{
|
{
|
||||||
var pid = StateTracker.GetRecordingPid();
|
var pid = _stateTracker.GetRecordingPid();
|
||||||
if (pid.HasValue)
|
if (pid.HasValue)
|
||||||
{
|
{
|
||||||
Logger.LogDebug($"Found active recording process with PID {pid.Value}. Attempting to stop...");
|
Logger.LogDebug($"Found active recording process with PID {pid.Value}. Attempting to stop...");
|
||||||
@@ -69,7 +79,7 @@ public static class AudioRecorder
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
StateTracker.ClearRecording();
|
_stateTracker.ClearRecording();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ public static class ConfigUpdaterCommand
|
|||||||
public static async Task ExecuteAsync(string key, string val, bool verbose)
|
public static async Task ExecuteAsync(string key, string val, bool verbose)
|
||||||
{
|
{
|
||||||
Toak.Core.Logger.Verbose = verbose;
|
Toak.Core.Logger.Verbose = verbose;
|
||||||
var config = ConfigManager.LoadConfig();
|
var configManager = new ConfigManager();
|
||||||
|
var config = configManager.LoadConfig();
|
||||||
key = key.ToLowerInvariant();
|
key = key.ToLowerInvariant();
|
||||||
val = val.ToLowerInvariant();
|
val = val.ToLowerInvariant();
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ public static class ConfigUpdaterCommand
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfigManager.SaveConfig(config);
|
configManager.SaveConfig(config);
|
||||||
AnsiConsole.MarkupLine($"[green]Successfully[/] set {key} to [blue]{val}[/].");
|
AnsiConsole.MarkupLine($"[green]Successfully[/] set {key} to [blue]{val}[/].");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ public static class HistoryCommand
|
|||||||
{
|
{
|
||||||
Logger.Verbose = verbose;
|
Logger.Verbose = verbose;
|
||||||
|
|
||||||
|
var historyManager = new HistoryManager();
|
||||||
|
|
||||||
if (shred)
|
if (shred)
|
||||||
{
|
{
|
||||||
HistoryManager.Shred();
|
historyManager.ClearHistory();
|
||||||
AnsiConsole.MarkupLine("[green]History successfully shredded.[/]");
|
AnsiConsole.MarkupLine("[green]History successfully shredded.[/]");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var entries = HistoryManager.LoadEntries();
|
var entries = historyManager.LoadHistory();
|
||||||
if (entries.Count == 0)
|
if (entries.Count == 0)
|
||||||
{
|
{
|
||||||
AnsiConsole.MarkupLine("[yellow]No history found.[/]");
|
AnsiConsole.MarkupLine("[yellow]No history found.[/]");
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public static class LatencyTestCommand
|
|||||||
public static async Task ExecuteAsync(bool verbose)
|
public static async Task ExecuteAsync(bool verbose)
|
||||||
{
|
{
|
||||||
Logger.Verbose = verbose;
|
Logger.Verbose = verbose;
|
||||||
var config = ConfigManager.LoadConfig();
|
var config = new ConfigManager().LoadConfig();
|
||||||
if (string.IsNullOrWhiteSpace(config.GroqApiKey))
|
if (string.IsNullOrWhiteSpace(config.GroqApiKey))
|
||||||
{
|
{
|
||||||
AnsiConsole.MarkupLine("[red]Groq API Key is not configured.[/] Run 'toak onboard'.");
|
AnsiConsole.MarkupLine("[red]Groq API Key is not configured.[/] Run 'toak onboard'.");
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ public static class OnboardCommand
|
|||||||
public static async Task ExecuteAsync(bool verbose)
|
public static async Task ExecuteAsync(bool verbose)
|
||||||
{
|
{
|
||||||
Toak.Core.Logger.Verbose = 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.Write(new FigletText("Toak").Color(Color.Green));
|
||||||
AnsiConsole.MarkupLine("[grey]Welcome to the Toak configuration wizard.[/]");
|
AnsiConsole.MarkupLine("[grey]Welcome to the Toak configuration wizard.[/]");
|
||||||
@@ -71,7 +72,7 @@ public static class OnboardCommand
|
|||||||
.AddChoices(availableSkills));
|
.AddChoices(availableSkills));
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfigManager.SaveConfig(config);
|
configManager.SaveConfig(config);
|
||||||
|
|
||||||
AnsiConsole.MarkupLine("\n[bold green]Configuration saved successfully![/]");
|
AnsiConsole.MarkupLine("\n[bold green]Configuration saved successfully![/]");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public static class ShowCommand
|
|||||||
public static async Task ExecuteAsync(bool verbose)
|
public static async Task ExecuteAsync(bool verbose)
|
||||||
{
|
{
|
||||||
Toak.Core.Logger.Verbose = verbose;
|
Toak.Core.Logger.Verbose = verbose;
|
||||||
var config = ConfigManager.LoadConfig();
|
var config = new ConfigManager().LoadConfig();
|
||||||
|
|
||||||
var table = new Table();
|
var table = new Table();
|
||||||
table.AddColumn("Setting");
|
table.AddColumn("Setting");
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public static class StatsCommand
|
|||||||
{
|
{
|
||||||
Logger.Verbose = verbose;
|
Logger.Verbose = verbose;
|
||||||
|
|
||||||
var entries = HistoryManager.LoadEntries();
|
var entries = new HistoryManager().LoadHistory();
|
||||||
if (entries.Count == 0)
|
if (entries.Count == 0)
|
||||||
{
|
{
|
||||||
AnsiConsole.MarkupLine("[yellow]No history found. Cannot generate statistics.[/]");
|
AnsiConsole.MarkupLine("[yellow]No history found. Cannot generate statistics.[/]");
|
||||||
|
|||||||
@@ -2,14 +2,21 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
using Toak.Serialization;
|
using Toak.Serialization;
|
||||||
|
using Toak.Core.Interfaces;
|
||||||
|
|
||||||
namespace Toak.Configuration;
|
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))
|
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))
|
if (!Directory.Exists(ConfigDir))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,19 +2,17 @@ using System;
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Toak.Audio;
|
|
||||||
using Toak.Configuration;
|
using Toak.Configuration;
|
||||||
using Toak.Api;
|
using Toak.Api;
|
||||||
|
using Toak.Core.Interfaces;
|
||||||
|
using Toak.Audio;
|
||||||
using Toak.IO;
|
using Toak.IO;
|
||||||
|
|
||||||
namespace Toak.Core;
|
namespace Toak.Core;
|
||||||
|
|
||||||
public static class DaemonService
|
public static class DaemonService
|
||||||
{
|
{
|
||||||
private static GroqApiClient? _groqClient;
|
|
||||||
|
|
||||||
public static string GetSocketPath()
|
public static string GetSocketPath()
|
||||||
{
|
{
|
||||||
var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR");
|
var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR");
|
||||||
@@ -49,14 +47,29 @@ public static class DaemonService
|
|||||||
try { File.Delete(socketPath); } catch { }
|
try { File.Delete(socketPath); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
var config = ConfigManager.LoadConfig();
|
var configManager = new ConfigManager();
|
||||||
|
var config = configManager.LoadConfig();
|
||||||
if (string.IsNullOrWhiteSpace(config.GroqApiKey))
|
if (string.IsNullOrWhiteSpace(config.GroqApiKey))
|
||||||
{
|
{
|
||||||
Console.WriteLine("Groq API Key is not configured. Run 'toak onboard'.");
|
Console.WriteLine("Groq API Key is not configured. Run 'toak onboard'.");
|
||||||
return;
|
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);
|
using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
||||||
var endPoint = new UnixDomainSocketEndPoint(socketPath);
|
var endPoint = new UnixDomainSocketEndPoint(socketPath);
|
||||||
@@ -71,7 +84,7 @@ public static class DaemonService
|
|||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
var client = await socket.AcceptAsync();
|
var client = await socket.AcceptAsync();
|
||||||
_ = Task.Run(() => HandleClientAsync(client));
|
_ = Task.Run(() => HandleClientAsync(client, orchestrator, stateTracker));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -101,22 +114,22 @@ public static class DaemonService
|
|||||||
|
|
||||||
if (cmd == 1) // START
|
if (cmd == 1) // START
|
||||||
{
|
{
|
||||||
await ProcessStartRecordingAsync();
|
await orchestrator.ProcessStartRecordingAsync();
|
||||||
}
|
}
|
||||||
else if (cmd == 2) // STOP
|
else if (cmd == 2) // STOP
|
||||||
{
|
{
|
||||||
await ProcessStopRecordingAsync(client, pipeToStdout, copyToClipboard);
|
await orchestrator.ProcessStopRecordingAsync(client, pipeToStdout, copyToClipboard);
|
||||||
}
|
}
|
||||||
else if (cmd == 3) // ABORT
|
else if (cmd == 3) // ABORT
|
||||||
{
|
{
|
||||||
ProcessAbortAsync();
|
orchestrator.ProcessAbortAsync();
|
||||||
}
|
}
|
||||||
else if (cmd == 4) // TOGGLE
|
else if (cmd == 4) // TOGGLE
|
||||||
{
|
{
|
||||||
if (StateTracker.IsRecording())
|
if (stateTracker.IsRecording())
|
||||||
await ProcessStopRecordingAsync(client, pipeToStdout, copyToClipboard);
|
await orchestrator.ProcessStopRecordingAsync(client, pipeToStdout, copyToClipboard);
|
||||||
else
|
else
|
||||||
await ProcessStartRecordingAsync();
|
await orchestrator.ProcessStartRecordingAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,114 +142,5 @@ public static class DaemonService
|
|||||||
client.Close();
|
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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ using System.IO;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Toak.Serialization;
|
using Toak.Serialization;
|
||||||
|
using Toak.Core.Interfaces;
|
||||||
|
|
||||||
namespace Toak.Core;
|
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 readonly string HistoryDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "toak");
|
||||||
private static readonly string HistoryFile = Path.Combine(HistoryDir, "history.jsonl");
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -44,7 +50,7 @@ public static class HistoryManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<HistoryEntry> LoadEntries()
|
public List<HistoryEntry> LoadHistory()
|
||||||
{
|
{
|
||||||
var entries = new List<HistoryEntry>();
|
var entries = new List<HistoryEntry>();
|
||||||
if (!File.Exists(HistoryFile)) return entries;
|
if (!File.Exists(HistoryFile)) return entries;
|
||||||
@@ -85,7 +91,7 @@ public static class HistoryManager
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Shred()
|
public void ClearHistory()
|
||||||
{
|
{
|
||||||
if (File.Exists(HistoryFile))
|
if (File.Exists(HistoryFile))
|
||||||
{
|
{
|
||||||
|
|||||||
11
Core/Interfaces/ITranscriptionOrchestrator.cs
Normal file
11
Core/Interfaces/ITranscriptionOrchestrator.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
62
Core/Interfaces/Interfaces.cs
Normal file
62
Core/Interfaces/Interfaces.cs
Normal file
@@ -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<string> TranscribeAsync(string filePath, string language = "", string model = "whisper-large-v3-turbo");
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ILlmClient
|
||||||
|
{
|
||||||
|
Task<string> RefineTextAsync(string rawTranscript, string systemPrompt, string model = "openai/gpt-oss-20b");
|
||||||
|
IAsyncEnumerable<string> 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<string> InjectStreamAsync(IAsyncEnumerable<string> 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<HistoryEntry> LoadHistory();
|
||||||
|
void ClearHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IClipboardManager
|
||||||
|
{
|
||||||
|
void Copy(string text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRecordingStateTracker
|
||||||
|
{
|
||||||
|
int? GetRecordingPid();
|
||||||
|
void SetRecording(int pid);
|
||||||
|
void ClearRecording();
|
||||||
|
bool IsRecording();
|
||||||
|
}
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
|
using Toak.Core.Interfaces;
|
||||||
|
|
||||||
namespace Toak.Core;
|
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);
|
return File.Exists(StateFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SetRecording(int ffmpegPid)
|
public void SetRecording(int ffmpegPid)
|
||||||
{
|
{
|
||||||
Logger.LogDebug($"Setting recording state with PID {ffmpegPid}");
|
Logger.LogDebug($"Setting recording state with PID {ffmpegPid}");
|
||||||
File.WriteAllText(StateFilePath, ffmpegPid.ToString());
|
File.WriteAllText(StateFilePath, ffmpegPid.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int? GetRecordingPid()
|
public int? GetRecordingPid()
|
||||||
{
|
{
|
||||||
if (File.Exists(StateFilePath))
|
if (File.Exists(StateFilePath))
|
||||||
{
|
{
|
||||||
@@ -29,7 +31,7 @@ public static class StateTracker
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void ClearRecording()
|
public void ClearRecording()
|
||||||
{
|
{
|
||||||
if (File.Exists(StateFilePath))
|
if (File.Exists(StateFilePath))
|
||||||
{
|
{
|
||||||
|
|||||||
153
Core/TranscriptionOrchestrator.cs
Normal file
153
Core/TranscriptionOrchestrator.cs
Normal file
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
using Toak.Core.Interfaces;
|
||||||
|
|
||||||
namespace Toak.IO;
|
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;
|
if (string.IsNullOrWhiteSpace(text)) return;
|
||||||
try
|
try
|
||||||
@@ -47,7 +56,7 @@ public static class ClipboardManager
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[ClipboardManager] Error copying text: {ex.Message}");
|
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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
using Toak.Core.Interfaces;
|
||||||
|
|
||||||
namespace Toak.IO;
|
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;
|
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;
|
if (!_paplayAvailable || string.IsNullOrWhiteSpace(soundPath)) return;
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -2,14 +2,23 @@ using System.Diagnostics;
|
|||||||
|
|
||||||
using Toak.Core;
|
using Toak.Core;
|
||||||
|
|
||||||
|
using Toak.Core.Interfaces;
|
||||||
|
|
||||||
namespace Toak.IO;
|
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}");
|
Logger.LogDebug($"Injecting text: '{text}' with {backend}");
|
||||||
if (string.IsNullOrWhiteSpace(text)) return;
|
if (string.IsNullOrWhiteSpace(text)) return Task.CompletedTask;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -42,11 +51,12 @@ public static class TextInjector
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[TextInjector] Error injecting text: {ex.Message}");
|
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<string> InjectStreamAsync(IAsyncEnumerable<string> tokenStream, string backend)
|
public async Task<string> InjectStreamAsync(IAsyncEnumerable<string> tokenStream, string backend)
|
||||||
{
|
{
|
||||||
string fullText = string.Empty;
|
string fullText = string.Empty;
|
||||||
try
|
try
|
||||||
@@ -97,7 +107,7 @@ public static class TextInjector
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[TextInjector] Error injecting text stream: {ex.Message}");
|
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;
|
return fullText;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user