554 lines
16 KiB
C#
554 lines
16 KiB
C#
using NAudio.Wave;
|
|
using Robovoice.App;
|
|
using Robovoice.Core;
|
|
using Robovoice.Core.Voices;
|
|
using Robovoice.Stt.Tcp;
|
|
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 TcpSttSource? _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;
|
|
txtServer.Text = _config.ServerEndpoint;
|
|
|
|
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;
|
|
txtServer.Leave += OnServerChanged;
|
|
btnConnect.Click += OnConnect;
|
|
|
|
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();
|
|
|
|
if (_sttSource is null)
|
|
{
|
|
_sttSource = new TcpSttSource { ServerEndpoint = txtServer.Text, Log = Log };
|
|
Log($"STT endpoint: {_sttSource.ServerEndpoint} (press Connect)");
|
|
}
|
|
|
|
_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.");
|
|
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");
|
|
try
|
|
{
|
|
_sttSource?.SendOn();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log($"SendOn failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void OnPttReleased(object? sender, EventArgs e)
|
|
{
|
|
Log("PTT released");
|
|
try
|
|
{
|
|
_sttSource?.SendOff();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log($"SendOff failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
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",
|
|
};
|
|
|
|
if (dlg.ShowDialog() == DialogResult.OK)
|
|
{
|
|
string text = File.ReadAllText(dlg.FileName);
|
|
lblFileName.Text = Path.GetFileName(dlg.FileName);
|
|
lblFileName.ForeColor = Color.Black;
|
|
Log($"Loaded: {dlg.FileName} ({text.Length} chars)");
|
|
_ = _orchestrator?.SynthesizeAsync(text);
|
|
}
|
|
}
|
|
|
|
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 OnServerChanged(object? sender, EventArgs e)
|
|
{
|
|
if (_sttSource is not null)
|
|
{
|
|
_sttSource.ServerEndpoint = txtServer.Text;
|
|
Log($"Server endpoint: {txtServer.Text}");
|
|
}
|
|
SaveConfig();
|
|
}
|
|
|
|
private async void OnConnect(object? sender, EventArgs e)
|
|
{
|
|
if (_sttSource is null)
|
|
{
|
|
Log("STT source not initialized.");
|
|
return;
|
|
}
|
|
|
|
_sttSource.ServerEndpoint = txtServer.Text;
|
|
SaveConfig();
|
|
btnConnect.Enabled = false;
|
|
try
|
|
{
|
|
if (_sttSource.IsRunning)
|
|
await _sttSource.ReconnectAsync();
|
|
else
|
|
await _sttSource.StartAsync();
|
|
}
|
|
finally
|
|
{
|
|
btnConnect.Enabled = true;
|
|
}
|
|
}
|
|
|
|
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.ServerEndpoint = txtServer.Text;
|
|
_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(2000);
|
|
_sttSource?.DisposeAsync().AsTask().Wait(2000);
|
|
SaveConfig();
|
|
}
|
|
}
|