using NAudio.Wave; using Robovoice.App; using Robovoice.Core; using Robovoice.Core.Voices; using Robovoice.Stt.File; using Robovoice.Tts.LibPiper; using System.Diagnostics; namespace Robovoice.App; internal sealed partial class MainForm : Form { private readonly string _voicesDir = @"C:\data\opencode\robovoice-voices"; private readonly string _espeakDataPath; private LibPiperTtsEngine? _tts; private AudioOutput? _audioOutput; private FileSttSource? _sttSource; private Orchestrator? _orchestrator; private PttHotkey? _pttHotkey; private NotifyIcon? _trayIcon; private bool _trayInit; public MainForm() { InitializeComponent(); _espeakDataPath = Path.Combine(AppContext.BaseDirectory, "espeak-ng-data"); Load += OnLoad; FormClosing += OnFormClosing; } private async void OnLoad(object? sender, EventArgs e) { PopulatePttKeys(); PopulateVoices(); PopulateOutputDevices(); cmbPttKey.SelectedItem = Keys.F8; if (cmbVoice.Items.Count > 0) cmbVoice.SelectedIndex = 0; AutoSelectCableOutput(); btnBrowseFile.Click += OnBrowseFile; btnTestVoice.Click += OnTestVoice; btnManageVoices.Click += OnManageVoices; btnClearLog.Click += (_, _) => txtLog.Clear(); cmbPttKey.SelectedIndexChanged += OnPttKeyChanged; cmbOutput.SelectedIndexChanged += OnOutputChanged; cmbVoice.SelectedIndexChanged += OnVoiceChanged; SetupTray(); await InitializeEngineAsync(); } private void PopulatePttKeys() { var keys = new[] { Keys.F1, Keys.F2, Keys.F3, Keys.F4, Keys.F5, Keys.F6, Keys.F7, Keys.F8, Keys.F9, Keys.F10, Keys.F11, Keys.F12, }; cmbPttKey.Items.AddRange(keys.Cast().ToArray()); } private void PopulateVoices() { cmbVoice.Items.Clear(); if (!Directory.Exists(_voicesDir)) return; foreach (var onnx in Directory.GetFiles(_voicesDir, "*.onnx")) { string name = Path.GetFileNameWithoutExtension(onnx); cmbVoice.Items.Add(name); } } private void PopulateOutputDevices() { cmbOutput.Items.Clear(); foreach (var (index, name) in AudioOutput.GetDevices()) { cmbOutput.Items.Add(name); } } private void AutoSelectCableOutput() { for (int i = 0; i < cmbOutput.Items.Count; i++) { if (cmbOutput.Items[i] is string s && s.Contains("CABLE", StringComparison.OrdinalIgnoreCase)) { cmbOutput.SelectedIndex = i; return; } } if (cmbOutput.Items.Count > 0) cmbOutput.SelectedIndex = 0; } private async Task InitializeEngineAsync() { if (cmbVoice.SelectedItem is not string voiceName) { Log("No voice selected."); return; } string modelPath = Path.Combine(_voicesDir, voiceName + ".onnx"); if (!File.Exists(modelPath)) { Log($"Model not found: {modelPath}"); return; } if (!Directory.Exists(_espeakDataPath)) { Log($"espeak-ng-data not found at: {_espeakDataPath}"); return; } Log($"Loading voice: {voiceName}..."); _audioOutput?.Dispose(); _tts?.DisposeAsync().AsTask().Wait(); _tts = new LibPiperTtsEngine(modelPath, _espeakDataPath); _audioOutput = new AudioOutput(); _sttSource = new FileSttSource(); _orchestrator?.DisposeAsync().AsTask().Wait(); _orchestrator = new Orchestrator(_tts, _audioOutput, _sttSource, Log) { OutputDeviceName = cmbOutput.SelectedItem as string ?? string.Empty, }; try { await _orchestrator.InitializeTtsAsync(); Log("Engine ready. Select a text file and press PTT key."); SetupHotkey(); } catch (Exception ex) { Log($"Init failed: {ex.Message}"); } } private void SetupHotkey() { _pttHotkey?.Dispose(); _pttHotkey = new PttHotkey { Key = (Keys)(cmbPttKey.SelectedItem ?? Keys.F8) }; _pttHotkey.Pressed += OnPttPressed; _pttHotkey.Released += OnPttReleased; _pttHotkey.Install(); Log($"PTT hotkey installed: {_pttHotkey.Key}"); } private void OnPttPressed(object? sender, EventArgs e) { Log("PTT pressed"); } private void OnPttReleased(object? sender, EventArgs e) { Log("PTT released"); if (_orchestrator is null || _sttSource is null) return; if (_sttSource.LineCount == 0) { Log("No text file loaded."); return; } UpdateLineStatus(); _orchestrator.TriggerNextLine(); UpdateLineStatus(); } private void OnBrowseFile(object? sender, EventArgs e) { using var dlg = new OpenFileDialog { Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*", Title = "Select a text file to read sequentially", }; if (dlg.ShowDialog() == DialogResult.OK) { _sttSource?.LoadFile(dlg.FileName); lblFileName.Text = Path.GetFileName(dlg.FileName); lblFileName.ForeColor = Color.Black; UpdateLineStatus(); Log($"Loaded: {dlg.FileName} ({_sttSource?.LineCount} lines)"); } } private void OnTestVoice(object? sender, EventArgs e) { if (_orchestrator is null) { Log("Engine not initialized."); return; } if (cmbVoice.SelectedItem is not string voiceName) { Log("No voice selected."); return; } if (!VoiceCatalogue.IsVoiceInstalled(_voicesDir, voiceName)) { Log($"Voice '{voiceName}' is not installed. Use Add/Remove to download it."); return; } string text = txtTextInput.Text.Trim(); if (string.IsNullOrEmpty(text)) text = "Hello, this is a voice test."; btnTestVoice.Enabled = false; try { _ = _orchestrator.SynthesizeAsync(text); } finally { btnTestVoice.Enabled = true; } } private void OnManageVoices(object? sender, EventArgs e) { string currentVoice = cmbVoice.SelectedItem as string ?? string.Empty; using var dlg = new VoiceManagerForm(_voicesDir, currentVoice); dlg.ShowDialog(this); PopulateVoices(); if (!string.IsNullOrEmpty(dlg.SelectedVoiceKey) && cmbVoice.Items.Contains(dlg.SelectedVoiceKey)) { cmbVoice.SelectedItem = dlg.SelectedVoiceKey; } else if (cmbVoice.Items.Count > 0) { cmbVoice.SelectedIndex = 0; } _ = InitializeEngineAsync(); } private void OnVoiceChanged(object? sender, EventArgs e) { btnTestVoice.Enabled = cmbVoice.SelectedItem is string voiceName && VoiceCatalogue.IsVoiceInstalled(_voicesDir, voiceName); if (cmbVoice.SelectedItem is string name) { _ = ReinitializeEngineAsync(name); } } private async Task ReinitializeEngineAsync(string voiceName) { if (!VoiceCatalogue.IsVoiceInstalled(_voicesDir, voiceName)) { btnTestVoice.Enabled = false; return; } await InitializeEngineAsync(); } private void OnPttKeyChanged(object? sender, EventArgs e) { SetupHotkey(); } private void OnOutputChanged(object? sender, EventArgs e) { if (_orchestrator is not null) _orchestrator.OutputDeviceName = cmbOutput.SelectedItem as string ?? string.Empty; Log($"Output device: {_orchestrator?.OutputDeviceName}"); } private void UpdateLineStatus() { if (_sttSource is null || _sttSource.LineCount == 0) { lblLineStatus.Text = ""; return; } int displayIdx = (_sttSource.CurrentIndex % _sttSource.LineCount) + 1; lblLineStatus.Text = $"Line {displayIdx}/{_sttSource.LineCount}"; } private void SetupTray() { if (_trayInit) return; _trayInit = true; _trayIcon = new NotifyIcon { Icon = SystemIcons.Application, Text = "Robovoice", Visible = true, }; var menu = new ContextMenuStrip(); menu.Items.Add("Show", null, (_, _) => ShowWindow()); menu.Items.Add("Exit", null, (_, _) => { _trayIcon.Visible = false; Application.Exit(); }); _trayIcon.ContextMenuStrip = menu; _trayIcon.DoubleClick += (_, _) => ShowWindow(); } private void ShowWindow() { Show(); WindowState = FormWindowState.Normal; Activate(); } private void Log(string message) { if (IsDisposed) return; if (InvokeRequired) { BeginInvoke(() => Log(message)); return; } string timestamp = DateTime.Now.ToString("HH:mm:ss"); txtLog.AppendText($"[{timestamp}] {message}\n"); txtLog.ScrollToCaret(); } private void OnFormClosing(object? sender, FormClosingEventArgs e) { if (e.CloseReason == CloseReason.UserClosing && chkMinimizeToTray.Checked && _trayIcon?.Visible == true) { e.Cancel = true; Hide(); return; } _pttHotkey?.Dispose(); _trayIcon!.Visible = false; _orchestrator?.DisposeAsync().AsTask().Wait(); } }