Files
Robovoice/Robovoice.App/MainForm.cs
T

356 lines
9.7 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.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<object>().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();
}
}