f7ea07b78e
- Stream audio directly to device via BufferedWaveProvider (no temp WAVs) - Add Noise/Speed/NoiseW sliders with live value labels - Sliders reinit engine on MouseUp (not Scroll) to avoid locking - Add text input field with Speak button for direct synthesis - Test button uses text input if non-empty - Fix sentence terminator auto-append (espeak-ng final-word drop) - Fix UTF-8 text marshaling (piper_synthesize_start keeps text ref) - Fix voice catalogue language column (JSON snake_case mapping) - Use piper_default_synthesize_options for model-specific defaults - Fix nullable warnings in designer files - Fix unused _playing field in AudioOutput
383 lines
11 KiB
C#
383 lines
11 KiB
C#
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;
|
|
|
|
trkNoise.Scroll += OnSliderScroll;
|
|
trkSpeed.Scroll += OnSliderScroll;
|
|
trkNoiseW.Scroll += OnSliderScroll;
|
|
trkNoise.MouseUp += OnSliderReleased;
|
|
trkSpeed.MouseUp += OnSliderReleased;
|
|
trkNoiseW.MouseUp += OnSliderReleased;
|
|
|
|
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,
|
|
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 = (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 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)
|
|
{
|
|
if (cmbVoice.SelectedItem is string name && VoiceCatalogue.IsVoiceInstalled(_voicesDir, name))
|
|
{
|
|
_ = 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();
|
|
}
|
|
}
|