Files
Robovoice/Robovoice.App/MainForm.cs
T

574 lines
16 KiB
C#
Raw Normal View History

2026-08-10 11:27:28 +00:00
using NAudio.Wave;
using Robovoice.App;
using Robovoice.Core;
using Robovoice.Core.Voices;
using Robovoice.Stt.Dhcp;
2026-08-10 11:27:28 +00:00
using Robovoice.Tts.LibPiper;
using System.Diagnostics;
namespace Robovoice.App;
internal sealed partial class MainForm : Form
{
private readonly string _voicesDir = AppConfig.VoicesDir;
2026-08-10 11:27:28 +00:00
private readonly string _espeakDataPath;
private readonly AppConfig _config;
2026-08-10 11:27:28 +00:00
private LibPiperTtsEngine? _tts;
private AudioOutput? _audioOutput;
private DhcpSttSource? _sttSource;
2026-08-10 11:27:28 +00:00
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}";
2026-08-10 11:27:28 +00:00
Load += OnLoad;
FormClosing += OnFormClosing;
}
private async void OnLoad(object? sender, EventArgs e)
{
ActiveControl = txtLog;
2026-08-10 11:27:28 +00:00
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)
2026-08-10 11:27:28 +00:00
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;
PopulateInterfaces();
2026-08-10 11:27:28 +00:00
btnBrowseFile.Click += OnBrowseFile;
btnTestVoice.Click += OnTestVoice;
btnManageVoices.Click += OnManageVoices;
btnClearLog.Click += (_, _) => txtLog.Clear();
txtPttKey.Enter += OnPttKeyFocus;
txtPttKey.KeyDown += OnPttKeyDown;
2026-08-10 11:27:28 +00:00
cmbOutput.SelectedIndexChanged += OnOutputChanged;
cmbVoice.SelectedIndexChanged += OnVoiceChanged;
cmbInterface.SelectedIndexChanged += OnInterfaceChanged;
2026-08-10 11:27:28 +00:00
trkNoise.Scroll += OnSliderScroll;
trkSpeed.Scroll += OnSliderScroll;
trkNoiseW.Scroll += OnSliderScroll;
trkNoise.MouseUp += OnSliderReleased;
trkSpeed.MouseUp += OnSliderReleased;
trkNoiseW.MouseUp += OnSliderReleased;
chkMinimizeToTray.CheckedChanged += (_, _) => SaveConfig();
Resize += OnResize;
2026-08-10 11:27:28 +00:00
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)
2026-08-10 11:27:28 +00:00
{
if (!_capturingPttKey) return;
if (key == Keys.Escape)
2026-08-10 11:27:28 +00:00
{
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(),
2026-08-10 11:27:28 +00:00
};
}
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);
2026-08-10 11:27:28 +00:00
_audioOutput = new AudioOutput();
if (_sttSource is null)
{
string ifaceIp = cmbInterface.SelectedItem as string ?? "";
_sttSource = new DhcpSttSource { InterfaceIp = ifaceIp, Log = Log };
await _sttSource.StartAsync();
}
2026-08-10 11:27:28 +00:00
_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. Press PTT to send to STT server.");
2026-08-10 11:27:28 +00:00
SetupHotkey();
}
catch (Exception ex)
{
Log($"Init failed: {ex.Message}");
}
}
private void SetupHotkey()
{
_pttHotkey?.Dispose();
_pttHotkey = new PttHotkey { Key = _pttKey };
2026-08-10 11:27:28 +00:00
_pttHotkey.Pressed += OnPttPressed;
_pttHotkey.Released += OnPttReleased;
_pttHotkey.Install();
Log($"PTT hotkey installed: {_pttHotkey.Key}");
}
private void OnPttPressed(object? sender, EventArgs e)
{
Log("PTT pressed");
_orchestrator?.NotifyPttPressed();
try
{
_sttSource?.SendOn();
}
catch (Exception ex)
{
Log($"SendOn failed: {ex.Message}");
}
2026-08-10 11:27:28 +00:00
}
private void OnPttReleased(object? sender, EventArgs e)
{
Log("PTT released");
try
2026-08-10 11:27:28 +00:00
{
_sttSource?.SendOff();
}
catch (Exception ex)
{
Log($"SendOff failed: {ex.Message}");
2026-08-10 11:27:28 +00:00
}
}
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 speak",
2026-08-10 11:27:28 +00:00
};
if (dlg.ShowDialog() == DialogResult.OK)
{
string text = File.ReadAllText(dlg.FileName);
2026-08-10 11:27:28 +00:00
lblFileName.Text = Path.GetFileName(dlg.FileName);
lblFileName.ForeColor = Color.Black;
Log($"Loaded: {dlg.FileName} ({text.Length} chars)");
_ = _orchestrator?.SynthesizeAsync(text);
2026-08-10 11:27:28 +00:00
}
}
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();
2026-08-10 11:27:28 +00:00
_ = 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();
2026-08-10 11:27:28 +00:00
_ = 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();
}
}
2026-08-10 11:27:28 +00:00
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();
2026-08-10 11:27:28 +00:00
}
private void PopulateInterfaces()
{
cmbInterface.Items.Clear();
foreach (var (ip, name) in DhcpSttSource.GetAvailableInterfaces())
{
cmbInterface.Items.Add(name);
cmbInterface.Items[^1] = name;
cmbInterface.Items[cmbInterface.Items.Count - 1] = name;
}
if (!string.IsNullOrEmpty(_config.InterfaceIp))
{
for (int i = 0; i < cmbInterface.Items.Count; i++)
{
if (cmbInterface.Items[i] is string s && s.Contains(_config.InterfaceIp))
{
cmbInterface.SelectedIndex = i;
return;
}
}
}
if (cmbInterface.Items.Count > 0)
cmbInterface.SelectedIndex = 0;
}
private void OnInterfaceChanged(object? sender, EventArgs e)
2026-08-10 11:27:28 +00:00
{
if (cmbInterface.SelectedItem is not string selected)
2026-08-10 11:27:28 +00:00
return;
string? ip = ExtractIpFromDisplay(selected);
if (ip is null) return;
_config.InterfaceIp = ip;
SaveConfig();
if (_sttSource is not null)
{
_sttSource.DisposeAsync().AsTask().Wait(2000);
_sttSource = null;
_ = InitializeEngineAsync();
}
2026-08-10 11:27:28 +00:00
}
private static string? ExtractIpFromDisplay(string display)
{
int start = display.IndexOf('(');
int end = display.IndexOf(')');
if (start < 0 || end <= start) return null;
return display[(start + 1)..end];
}
2026-08-10 11:27:28 +00:00
private void SetupTray()
{
if (_trayInit) return;
_trayInit = true;
var micIcon = IconExtractor.TryExtractMicrophone();
if (micIcon is not null)
2026-08-10 11:27:28 +00:00
{
_trayIcon = new NotifyIcon
{
Icon = micIcon,
Text = $"Robovoice {AppVersion}",
Visible = true,
};
Icon = micIcon;
}
else
{
_trayIcon = new NotifyIcon
{
Icon = SystemIcons.Application,
Text = $"Robovoice {AppVersion}",
Visible = true,
};
}
2026-08-10 11:27:28 +00:00
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;
}
}
2026-08-10 11:27:28 +00:00
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.InterfaceIp = ExtractIpFromDisplay(cmbInterface.SelectedItem as string ?? "") ?? "";
_config.Save();
}
2026-08-10 11:27:28 +00:00
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(2000);
_sttSource?.DisposeAsync().AsTask().Wait(2000);
SaveConfig();
2026-08-10 11:27:28 +00:00
}
}