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 = AppConfig.VoicesDir; private readonly string _espeakDataPath; private readonly AppConfig _config; 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"); _config = AppConfig.Load(); Directory.CreateDirectory(AppConfig.AppDataDir); Directory.CreateDirectory(_voicesDir); Text = $"Robovoice {AppVersion}"; Load += OnLoad; FormClosing += OnFormClosing; } private async void OnLoad(object? sender, EventArgs e) { ActiveControl = txtLog; PopulateVoices(); PopulateOutputDevices(); _pttKey = (Keys)_config.PttKey; if (_pttKey == Keys.None) _pttKey = Keys.F8; txtPttKey.Text = KeyToDisplayString(_pttKey); if (!string.IsNullOrEmpty(_config.Voice) && cmbVoice.Items.Contains(_config.Voice)) cmbVoice.SelectedItem = _config.Voice; else if (cmbVoice.Items.Count > 0) cmbVoice.SelectedIndex = 0; if (!string.IsNullOrEmpty(_config.OutputDevice) && cmbOutput.Items.Contains(_config.OutputDevice)) cmbOutput.SelectedItem = _config.OutputDevice; else AutoSelectCableOutput(); trkNoise.Value = _config.NoiseScale; trkSpeed.Value = _config.LengthScale; trkNoiseW.Value = _config.NoiseWScale; OnSliderScroll(null, EventArgs.Empty); chkMinimizeToTray.Checked = _config.MinimizeToTray; btnBrowseFile.Click += OnBrowseFile; btnTestVoice.Click += OnTestVoice; btnManageVoices.Click += OnManageVoices; btnClearLog.Click += (_, _) => txtLog.Clear(); txtPttKey.Enter += OnPttKeyFocus; txtPttKey.KeyDown += OnPttKeyDown; cmbOutput.SelectedIndexChanged += OnOutputChanged; cmbVoice.SelectedIndexChanged += OnVoiceChanged; trkNoise.Scroll += OnSliderScroll; trkSpeed.Scroll += OnSliderScroll; trkNoiseW.Scroll += OnSliderScroll; trkNoise.MouseUp += OnSliderReleased; trkSpeed.MouseUp += OnSliderReleased; trkNoiseW.MouseUp += OnSliderReleased; chkMinimizeToTray.CheckedChanged += (_, _) => SaveConfig(); Resize += OnResize; SetupTray(); await InitializeEngineAsync(); } private Keys _pttKey = Keys.F8; private bool _capturingPttKey; private PttHotkey? _captureHook; private void OnPttKeyFocus(object? sender, EventArgs e) { _capturingPttKey = true; txtPttKey.Text = "Press a key..."; txtPttKey.BackColor = Color.LightYellow; _captureHook?.Dispose(); _captureHook = new PttHotkey(); _captureHook.CaptureKeyPressed += OnCaptureKey; _captureHook.InstallCaptureHook(); } private void OnCaptureKey(Keys key) { if (!_capturingPttKey) return; if (key == Keys.Escape) { CancelCapture(); return; } if (key is Keys.ShiftKey or Keys.Menu or Keys.LWin or Keys.RWin) return; _pttKey = key; _capturingPttKey = false; txtPttKey.Text = KeyToDisplayString(_pttKey); txtPttKey.BackColor = SystemColors.Window; _captureHook?.Dispose(); _captureHook = null; SetupHotkey(); SaveConfig(); } private void CancelCapture() { _capturingPttKey = false; txtPttKey.Text = KeyToDisplayString(_pttKey); txtPttKey.BackColor = SystemColors.Window; _captureHook?.Dispose(); _captureHook = null; } private void OnPttKeyDown(object? sender, KeyEventArgs e) { if (_capturingPttKey && e.KeyCode == Keys.Escape) { CancelCapture(); e.SuppressKeyPress = true; } } private static string KeyToDisplayString(Keys key) { return key switch { >= Keys.F1 and <= Keys.F12 => key.ToString(), Keys.RControlKey => "Right Ctrl", Keys.LControlKey => "Left Ctrl", Keys.Space => "Space", Keys.LButton => "Mouse Left", Keys.RButton => "Mouse Right", Keys.MButton => "Mouse Middle", Keys.XButton1 => "Mouse X1", Keys.XButton2 => "Mouse X2", Keys.Oemtilde => "`", Keys.CapsLock => "CapsLock", Keys.NumLock => "NumLock", Keys.Scroll => "ScrollLock", Keys.Pause => "Pause", Keys.Insert => "Insert", Keys.Delete => "Delete", Keys.Home => "Home", Keys.End => "End", Keys.PageUp => "PageUp", Keys.PageDown => "PageDown", _ => key.ToString(), }; } 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, noiseScale: trkNoise.Value / 1000.0f, lengthScale: trkSpeed.Value / 100.0f, noiseWScale: trkNoiseW.Value / 1000.0f); _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 = _pttKey }; _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; } SaveConfig(); _ = 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) { SaveConfig(); _ = ReinitializeEngineAsync(name); } } private async Task ReinitializeEngineAsync(string voiceName) { if (!VoiceCatalogue.IsVoiceInstalled(_voicesDir, voiceName)) { btnTestVoice.Enabled = false; return; } await InitializeEngineAsync(); } private void OnSliderScroll(object? sender, EventArgs e) { lblNoiseVal.Text = $"{trkNoise.Value / 1000.0:F3}"; lblSpeedVal.Text = $"{trkSpeed.Value / 100.0:F2}"; lblNoiseWVal.Text = $"{trkNoiseW.Value / 1000.0:F3}"; } private void OnSliderReleased(object? sender, MouseEventArgs e) { SaveConfig(); if (cmbVoice.SelectedItem is string name && VoiceCatalogue.IsVoiceInstalled(_voicesDir, name)) { _ = InitializeEngineAsync(); } } 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}"); SaveConfig(); } 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; var micIcon = IconExtractor.TryExtractMicrophone(); if (micIcon is not null) { _trayIcon = new NotifyIcon { Icon = micIcon, Text = $"Robovoice {AppVersion}", Visible = true, }; Icon = micIcon; } else { _trayIcon = new NotifyIcon { Icon = SystemIcons.Application, Text = $"Robovoice {AppVersion}", 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 static string AppVersion => typeof(MainForm).Assembly.GetName().Version?.ToString() ?? "0.0"; private void OnResize(object? sender, EventArgs e) { if (WindowState == FormWindowState.Minimized && chkMinimizeToTray.Checked) { Hide(); WindowState = FormWindowState.Normal; } } private void ShowWindow() { Show(); WindowState = FormWindowState.Normal; Activate(); } private void SaveConfig() { _config.Voice = cmbVoice.SelectedItem as string ?? string.Empty; _config.PttKey = (int)_pttKey; _config.OutputDevice = cmbOutput.SelectedItem as string ?? string.Empty; _config.NoiseScale = trkNoise.Value; _config.LengthScale = trkSpeed.Value; _config.NoiseWScale = trkNoiseW.Value; _config.MinimizeToTray = chkMinimizeToTray.Checked; _config.Save(); } 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) { _pttHotkey?.Dispose(); _trayIcon!.Visible = false; _orchestrator?.DisposeAsync().AsTask().Wait(); SaveConfig(); } }