v0.1: Robovoice — PTT re-voicing app
WinForms tray app that captures text (from file or direct input) and synthesizes speech via libpiper (Piper TTS), outputting to any audio device (VB-CABLE for virtual mic routing). Features: - Global PTT hotkey (configurable F1-F12) via WH_KEYBOARD_LL - Text file sequential reader (line-by-line on each PTT cycle) - Direct text input with Speak button - Voice manager: browse 147-voice Piper catalogue, download, remove - libpiper P/Invoke wrapper with UTF-8 marshaling, streaming chunks - BufferedWaveProvider streaming playback with trailing silence flush - Sentence terminator auto-append (fixes espeak-ng final-word drop) - Tray icon with minimize-to-tray Architecture: - Robovoice.Core: ITtsEngine, ISttSource interfaces, voice catalogue - Robovoice.Tts.LibPiper: P/Invoke wrapper for libpiper.dll - Robovoice.Stt.File: text file STT source (testing without mic server) - Robovoice.Stt.Udp: UDP client stub (for future Linux mic server) - Robovoice.App: WinForms UI, orchestrator, PTT hotkey, audio output
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace Robovoice.App;
|
||||
|
||||
internal sealed class AudioOutput : IDisposable
|
||||
{
|
||||
private WaveOutEvent? _waveOut;
|
||||
private BufferedWaveProvider? _bufferProvider;
|
||||
private int _sampleRate;
|
||||
private string _deviceName = string.Empty;
|
||||
private bool _playing;
|
||||
private bool _disposed;
|
||||
|
||||
public Action<string>? Log { get; set; }
|
||||
|
||||
public void Start(int sampleRate, string? deviceName = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_sampleRate = sampleRate;
|
||||
_deviceName = deviceName ?? string.Empty;
|
||||
|
||||
Stop();
|
||||
|
||||
int deviceNumber = FindDevice(_deviceName);
|
||||
_waveOut = new WaveOutEvent { DeviceNumber = deviceNumber, DesiredLatency = 200 };
|
||||
|
||||
_bufferProvider = new BufferedWaveProvider(
|
||||
WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, 1))
|
||||
{
|
||||
BufferDuration = TimeSpan.FromSeconds(60),
|
||||
DiscardOnBufferOverflow = true,
|
||||
ReadFully = true,
|
||||
};
|
||||
|
||||
_waveOut.Init(_bufferProvider);
|
||||
_waveOut.PlaybackStopped += OnPlaybackStopped;
|
||||
_playing = true;
|
||||
_waveOut.Play();
|
||||
}
|
||||
|
||||
public void WriteSamples(float[] samples)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_bufferProvider is null) return;
|
||||
|
||||
byte[] bytes = new byte[samples.Length * sizeof(float)];
|
||||
Buffer.BlockCopy(samples, 0, bytes, 0, bytes.Length);
|
||||
_bufferProvider.AddSamples(bytes, 0, bytes.Length);
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_bufferProvider is null || _sampleRate == 0) return;
|
||||
|
||||
int padSamples = (int)(_sampleRate * 0.5);
|
||||
WriteSamples(new float[padSamples]);
|
||||
}
|
||||
|
||||
private void OnPlaybackStopped(object? sender, StoppedEventArgs e)
|
||||
{
|
||||
_playing = false;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_waveOut is not null)
|
||||
{
|
||||
_waveOut.PlaybackStopped -= OnPlaybackStopped;
|
||||
_waveOut.Stop();
|
||||
_waveOut.Dispose();
|
||||
_waveOut = null;
|
||||
}
|
||||
_bufferProvider?.ClearBuffer();
|
||||
_bufferProvider = null;
|
||||
_playing = false;
|
||||
}
|
||||
|
||||
private static int FindDevice(string? deviceName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(deviceName))
|
||||
return -1;
|
||||
|
||||
for (int i = 0; i < WaveOut.DeviceCount; i++)
|
||||
{
|
||||
var caps = WaveOut.GetCapabilities(i);
|
||||
if (caps.ProductName.Contains(deviceName, StringComparison.OrdinalIgnoreCase))
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<(int Index, string Name)> GetDevices()
|
||||
{
|
||||
var devices = new List<(int, string)>();
|
||||
for (int i = 0; i < WaveOut.DeviceCount; i++)
|
||||
{
|
||||
var caps = WaveOut.GetCapabilities(i);
|
||||
devices.Add((i, caps.ProductName));
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
Stop();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
Generated
+193
@@ -0,0 +1,193 @@
|
||||
namespace Robovoice.App;
|
||||
|
||||
partial class MainForm
|
||||
{
|
||||
private System.ComponentModel.IContainer? components = null;
|
||||
|
||||
private Label lblPttKey = null!;
|
||||
private ComboBox cmbPttKey = null!;
|
||||
private Label lblVoice = null!;
|
||||
private ComboBox cmbVoice = null!;
|
||||
private Button btnTestVoice = null!;
|
||||
private Button btnManageVoices = null!;
|
||||
private Label lblOutput = null!;
|
||||
private ComboBox cmbOutput = null!;
|
||||
private Label lblFile = null!;
|
||||
private Button btnBrowseFile = null!;
|
||||
private Label lblFileName = null!;
|
||||
private Label lblLineStatus = null!;
|
||||
private RichTextBox txtLog = null!;
|
||||
private CheckBox chkMinimizeToTray = null!;
|
||||
private Button btnClearLog = null!;
|
||||
private Label lblTextInput = null!;
|
||||
private TextBox txtTextInput = null!;
|
||||
private Button btnSpeakInput = null!;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && components != null)
|
||||
components.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
components = new System.ComponentModel.Container();
|
||||
|
||||
lblPttKey = new Label();
|
||||
cmbPttKey = new ComboBox();
|
||||
lblVoice = new Label();
|
||||
cmbVoice = new ComboBox();
|
||||
btnTestVoice = new Button();
|
||||
btnManageVoices = new Button();
|
||||
lblOutput = new Label();
|
||||
cmbOutput = new ComboBox();
|
||||
lblFile = new Label();
|
||||
btnBrowseFile = new Button();
|
||||
lblFileName = new Label();
|
||||
lblLineStatus = new Label();
|
||||
txtLog = new RichTextBox();
|
||||
chkMinimizeToTray = new CheckBox();
|
||||
btnClearLog = new Button();
|
||||
lblTextInput = new Label();
|
||||
txtTextInput = new TextBox();
|
||||
btnSpeakInput = new Button();
|
||||
|
||||
SuspendLayout();
|
||||
|
||||
// lblPttKey
|
||||
lblPttKey.Text = "PTT Key:";
|
||||
lblPttKey.Location = new Point(12, 15);
|
||||
lblPttKey.Size = new Size(60, 23);
|
||||
lblPttKey.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// cmbPttKey
|
||||
cmbPttKey.Location = new Point(75, 12);
|
||||
cmbPttKey.Size = new Size(80, 23);
|
||||
cmbPttKey.DropDownStyle = ComboBoxStyle.DropDownList;
|
||||
|
||||
// lblVoice
|
||||
lblVoice.Text = "Voice:";
|
||||
lblVoice.Location = new Point(170, 15);
|
||||
lblVoice.Size = new Size(45, 23);
|
||||
lblVoice.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// cmbVoice
|
||||
cmbVoice.Location = new Point(218, 12);
|
||||
cmbVoice.Size = new Size(200, 23);
|
||||
cmbVoice.DropDownStyle = ComboBoxStyle.DropDownList;
|
||||
|
||||
// btnTestVoice
|
||||
btnTestVoice.Text = "Test";
|
||||
btnTestVoice.Location = new Point(423, 11);
|
||||
btnTestVoice.Size = new Size(45, 25);
|
||||
btnTestVoice.UseVisualStyleBackColor = true;
|
||||
|
||||
// btnManageVoices
|
||||
btnManageVoices.Text = "Add/Remove...";
|
||||
btnManageVoices.Location = new Point(474, 11);
|
||||
btnManageVoices.Size = new Size(95, 25);
|
||||
btnManageVoices.UseVisualStyleBackColor = true;
|
||||
|
||||
// lblOutput
|
||||
lblOutput.Text = "Output:";
|
||||
lblOutput.Location = new Point(580, 15);
|
||||
lblOutput.Size = new Size(50, 23);
|
||||
lblOutput.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// cmbOutput
|
||||
cmbOutput.Location = new Point(633, 12);
|
||||
cmbOutput.Size = new Size(180, 23);
|
||||
cmbOutput.DropDownStyle = ComboBoxStyle.DropDownList;
|
||||
|
||||
// lblFile
|
||||
lblFile.Text = "Text File:";
|
||||
lblFile.Location = new Point(12, 48);
|
||||
lblFile.Size = new Size(60, 23);
|
||||
lblFile.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// btnBrowseFile
|
||||
btnBrowseFile.Text = "Browse...";
|
||||
btnBrowseFile.Location = new Point(75, 45);
|
||||
btnBrowseFile.Size = new Size(75, 25);
|
||||
btnBrowseFile.UseVisualStyleBackColor = true;
|
||||
|
||||
// lblFileName
|
||||
lblFileName.Text = "(none)";
|
||||
lblFileName.Location = new Point(155, 48);
|
||||
lblFileName.Size = new Size(490, 23);
|
||||
lblFileName.TextAlign = ContentAlignment.MiddleLeft;
|
||||
lblFileName.ForeColor = Color.Gray;
|
||||
|
||||
// lblLineStatus
|
||||
lblLineStatus.Text = "";
|
||||
lblLineStatus.Location = new Point(650, 48);
|
||||
lblLineStatus.Size = new Size(160, 23);
|
||||
lblLineStatus.TextAlign = ContentAlignment.MiddleRight;
|
||||
lblLineStatus.ForeColor = Color.DarkBlue;
|
||||
|
||||
// txtLog
|
||||
txtLog.Location = new Point(12, 112);
|
||||
txtLog.Size = new Size(800, 310);
|
||||
|
||||
// lblTextInput
|
||||
lblTextInput.Text = "Text:";
|
||||
lblTextInput.Location = new Point(12, 84);
|
||||
lblTextInput.Size = new Size(35, 23);
|
||||
lblTextInput.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// txtTextInput
|
||||
txtTextInput.Location = new Point(50, 81);
|
||||
txtTextInput.Size = new Size(680, 23);
|
||||
|
||||
// btnSpeakInput
|
||||
btnSpeakInput.Text = "Speak";
|
||||
btnSpeakInput.Location = new Point(737, 80);
|
||||
btnSpeakInput.Size = new Size(75, 25);
|
||||
btnSpeakInput.UseVisualStyleBackColor = true;
|
||||
txtLog.ReadOnly = true;
|
||||
txtLog.Font = new Font("Consolas", 9F);
|
||||
txtLog.BackColor = Color.FromArgb(30, 30, 30);
|
||||
txtLog.ForeColor = Color.FromArgb(220, 220, 220);
|
||||
|
||||
// chkMinimizeToTray
|
||||
chkMinimizeToTray.Text = "Minimize to tray on close";
|
||||
chkMinimizeToTray.Location = new Point(12, 440);
|
||||
chkMinimizeToTray.Size = new Size(180, 24);
|
||||
chkMinimizeToTray.UseVisualStyleBackColor = true;
|
||||
|
||||
// btnClearLog
|
||||
btnClearLog.Text = "Clear Log";
|
||||
btnClearLog.Location = new Point(737, 438);
|
||||
btnClearLog.Size = new Size(75, 25);
|
||||
btnClearLog.UseVisualStyleBackColor = true;
|
||||
|
||||
// MainForm
|
||||
AutoScaleDimensions = new SizeF(7F, 15F);
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(824, 472);
|
||||
Controls.Add(lblPttKey);
|
||||
Controls.Add(cmbPttKey);
|
||||
Controls.Add(lblVoice);
|
||||
Controls.Add(cmbVoice);
|
||||
Controls.Add(btnTestVoice);
|
||||
Controls.Add(btnManageVoices);
|
||||
Controls.Add(lblOutput);
|
||||
Controls.Add(cmbOutput);
|
||||
Controls.Add(lblFile);
|
||||
Controls.Add(btnBrowseFile);
|
||||
Controls.Add(lblFileName);
|
||||
Controls.Add(lblLineStatus);
|
||||
Controls.Add(lblTextInput);
|
||||
Controls.Add(txtTextInput);
|
||||
Controls.Add(btnSpeakInput);
|
||||
Controls.Add(txtLog);
|
||||
Controls.Add(chkMinimizeToTray);
|
||||
Controls.Add(btnClearLog);
|
||||
MinimumSize = new Size(840, 510);
|
||||
Text = "Robovoice";
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||
MaximizeBox = false;
|
||||
ResumeLayout(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Robovoice.Core;
|
||||
using Robovoice.Stt.File;
|
||||
using Robovoice.Tts.LibPiper;
|
||||
|
||||
namespace Robovoice.App;
|
||||
|
||||
internal sealed class Orchestrator : IAsyncDisposable
|
||||
{
|
||||
private readonly LibPiperTtsEngine _tts;
|
||||
private readonly AudioOutput _audioOutput;
|
||||
private readonly FileSttSource _sttSource;
|
||||
private readonly Action<string> _log;
|
||||
private CancellationTokenSource? _currentCts;
|
||||
private bool _disposed;
|
||||
|
||||
public string OutputDeviceName { get; set; } = string.Empty;
|
||||
|
||||
public Orchestrator(
|
||||
LibPiperTtsEngine tts,
|
||||
AudioOutput audioOutput,
|
||||
FileSttSource sttSource,
|
||||
Action<string> log)
|
||||
{
|
||||
_tts = tts;
|
||||
_audioOutput = audioOutput;
|
||||
_audioOutput.Log = log;
|
||||
_sttSource = sttSource;
|
||||
_log = log;
|
||||
_sttSource.TranscriptReceived += OnTranscript;
|
||||
}
|
||||
|
||||
private void OnTranscript(object? sender, TranscriptEventArgs e)
|
||||
{
|
||||
if (e.Message.Type == TranscriptType.Final)
|
||||
{
|
||||
_ = SynthesizeAsync(e.Message.Text);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InitializeTtsAsync()
|
||||
{
|
||||
_log("Initializing TTS engine...");
|
||||
await _tts.InitializeAsync();
|
||||
_log($"TTS ready (sample rate: {_tts.SampleRate} Hz)");
|
||||
}
|
||||
|
||||
public async Task SynthesizeAsync(string text)
|
||||
{
|
||||
_currentCts?.Cancel();
|
||||
_currentCts = new CancellationTokenSource();
|
||||
var ct = _currentCts.Token;
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
_log($"FINAL: \"{text}\" ({text.Length} chars)");
|
||||
|
||||
try
|
||||
{
|
||||
bool started = false;
|
||||
int chunkCount = 0;
|
||||
int totalSamples = 0;
|
||||
|
||||
await foreach (var chunk in _tts.SynthesizeAsync(text, ct))
|
||||
{
|
||||
if (!started)
|
||||
{
|
||||
started = true;
|
||||
_audioOutput.Start(chunk.SampleRate, OutputDeviceName);
|
||||
_log($"TTS: first chunk ({sw.ElapsedMilliseconds}ms)");
|
||||
}
|
||||
|
||||
_audioOutput.WriteSamples(chunk.Samples);
|
||||
chunkCount++;
|
||||
totalSamples += chunk.Samples.Length;
|
||||
}
|
||||
|
||||
if (!started)
|
||||
{
|
||||
_log("TTS: no audio produced");
|
||||
}
|
||||
else
|
||||
{
|
||||
_audioOutput.Flush();
|
||||
double durationSec = (double)totalSamples / _tts.SampleRate;
|
||||
_log($"TTS: done ({chunkCount} chunks, {durationSec:F2}s audio, {sw.ElapsedMilliseconds}ms)");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_log("TTS: cancelled");
|
||||
_audioOutput.Stop();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log($"TTS error: {ex.Message}");
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
}
|
||||
|
||||
public void TriggerNextLine()
|
||||
{
|
||||
_sttSource.EmitNext();
|
||||
}
|
||||
|
||||
public string? GetPendingLine() => _sttSource.GetPendingLine();
|
||||
public int GetCurrentLineIndex() => _sttSource.CurrentIndex;
|
||||
public int GetLineCount() => _sttSource.LineCount;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_currentCts?.Cancel();
|
||||
_currentCts?.Dispose();
|
||||
_sttSource.TranscriptReceived -= OnTranscript;
|
||||
await _sttSource.DisposeAsync();
|
||||
await _tts.DisposeAsync();
|
||||
_audioOutput.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Robovoice.App;
|
||||
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new MainForm());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace Robovoice.App;
|
||||
|
||||
internal sealed class PttHotkey : IDisposable
|
||||
{
|
||||
private const int WH_KEYBOARD_LL = 13;
|
||||
private const int WM_KEYDOWN = 0x0100;
|
||||
private const int WM_KEYUP = 0x0101;
|
||||
private const int WM_SYSKEYDOWN = 0x0104;
|
||||
private const int WM_SYSKEYUP = 0x0105;
|
||||
|
||||
private readonly LowLevelKeyboardProc _proc;
|
||||
private IntPtr _hook = IntPtr.Zero;
|
||||
private bool _isDown;
|
||||
private bool _disposed;
|
||||
|
||||
public Keys Key { get; set; } = Keys.F8;
|
||||
|
||||
public event EventHandler? Pressed;
|
||||
public event EventHandler? Released;
|
||||
|
||||
public PttHotkey()
|
||||
{
|
||||
_proc = HookCallback;
|
||||
}
|
||||
|
||||
public void Install()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_hook != IntPtr.Zero) return;
|
||||
|
||||
IntPtr hModule = GetModuleHandle(null);
|
||||
_hook = SetWindowsHookEx(WH_KEYBOARD_LL, _proc, hModule, 0);
|
||||
}
|
||||
|
||||
public void Uninstall()
|
||||
{
|
||||
if (_hook != IntPtr.Zero)
|
||||
{
|
||||
UnhookWindowsHookEx(_hook);
|
||||
_hook = IntPtr.Zero;
|
||||
_isDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (nCode >= 0)
|
||||
{
|
||||
int vkCode = Marshal.ReadInt32(lParam);
|
||||
Keys key = (Keys)vkCode;
|
||||
|
||||
bool isDown = wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN;
|
||||
bool isUp = wParam == WM_KEYUP || wParam == WM_SYSKEYUP;
|
||||
|
||||
if (key == Key && isDown && !_isDown)
|
||||
{
|
||||
_isDown = true;
|
||||
Pressed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
else if (key == Key && isUp && _isDown)
|
||||
{
|
||||
_isDown = false;
|
||||
Released?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
return CallNextHookEx(_hook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
Uninstall();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr GetModuleHandle(string? lpModuleName);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Robovoice.Core\Robovoice.Core.csproj" />
|
||||
<ProjectReference Include="..\Robovoice.Tts.LibPiper\Robovoice.Tts.LibPiper.csproj" />
|
||||
<ProjectReference Include="..\Robovoice.Stt.File\Robovoice.Stt.File.csproj" />
|
||||
<ProjectReference Include="..\Robovoice.Stt.Udp\Robovoice.Stt.Udp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
namespace Robovoice.App;
|
||||
|
||||
partial class VoiceManagerForm
|
||||
{
|
||||
private System.ComponentModel.IContainer? components = null;
|
||||
|
||||
private Label lblFilter = null!;
|
||||
private TextBox txtFilter = null!;
|
||||
private DataGridView gridVoices = null!;
|
||||
private Button btnDownload = null!;
|
||||
private Button btnRemove = null!;
|
||||
private Button btnClose = null!;
|
||||
private Label lblStatus = null!;
|
||||
private ProgressBar progressBar = null!;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && components != null)
|
||||
components.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
components = new System.ComponentModel.Container();
|
||||
|
||||
lblFilter = new Label();
|
||||
txtFilter = new TextBox();
|
||||
gridVoices = new DataGridView();
|
||||
btnDownload = new Button();
|
||||
btnRemove = new Button();
|
||||
btnClose = new Button();
|
||||
lblStatus = new Label();
|
||||
progressBar = new ProgressBar();
|
||||
|
||||
((System.ComponentModel.ISupportInitialize)gridVoices).BeginInit();
|
||||
SuspendLayout();
|
||||
|
||||
// lblFilter
|
||||
lblFilter.Text = "Filter:";
|
||||
lblFilter.Location = new Point(12, 15);
|
||||
lblFilter.Size = new Size(45, 23);
|
||||
lblFilter.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// txtFilter
|
||||
txtFilter.Location = new Point(60, 12);
|
||||
txtFilter.Size = new Size(300, 23);
|
||||
|
||||
// gridVoices
|
||||
gridVoices.Location = new Point(12, 42);
|
||||
gridVoices.Size = new Size(660, 380);
|
||||
gridVoices.AllowUserToAddRows = false;
|
||||
gridVoices.AllowUserToDeleteRows = false;
|
||||
gridVoices.AllowUserToResizeRows = false;
|
||||
gridVoices.ReadOnly = true;
|
||||
gridVoices.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
|
||||
gridVoices.MultiSelect = false;
|
||||
gridVoices.RowHeadersVisible = false;
|
||||
gridVoices.BackgroundColor = SystemColors.Window;
|
||||
gridVoices.BorderStyle = BorderStyle.FixedSingle;
|
||||
gridVoices.CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal;
|
||||
gridVoices.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize;
|
||||
gridVoices.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
|
||||
|
||||
// btnDownload
|
||||
btnDownload.Text = "Download";
|
||||
btnDownload.Location = new Point(12, 430);
|
||||
btnDownload.Size = new Size(90, 28);
|
||||
btnDownload.UseVisualStyleBackColor = true;
|
||||
|
||||
// btnRemove
|
||||
btnRemove.Text = "Remove";
|
||||
btnRemove.Location = new Point(108, 430);
|
||||
btnRemove.Size = new Size(90, 28);
|
||||
btnRemove.UseVisualStyleBackColor = true;
|
||||
|
||||
// btnClose
|
||||
btnClose.Text = "Close";
|
||||
btnClose.Location = new Point(582, 430);
|
||||
btnClose.Size = new Size(90, 28);
|
||||
btnClose.UseVisualStyleBackColor = true;
|
||||
|
||||
// lblStatus
|
||||
lblStatus.Text = "";
|
||||
lblStatus.Location = new Point(12, 465);
|
||||
lblStatus.Size = new Size(660, 20);
|
||||
lblStatus.TextAlign = ContentAlignment.MiddleLeft;
|
||||
lblStatus.ForeColor = Color.DarkBlue;
|
||||
|
||||
// progressBar
|
||||
progressBar.Location = new Point(208, 433);
|
||||
progressBar.Size = new Size(360, 23);
|
||||
progressBar.Visible = false;
|
||||
|
||||
// VoiceManagerForm
|
||||
AutoScaleDimensions = new SizeF(7F, 15F);
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(684, 495);
|
||||
Controls.Add(lblFilter);
|
||||
Controls.Add(txtFilter);
|
||||
Controls.Add(gridVoices);
|
||||
Controls.Add(btnDownload);
|
||||
Controls.Add(btnRemove);
|
||||
Controls.Add(btnClose);
|
||||
Controls.Add(lblStatus);
|
||||
Controls.Add(progressBar);
|
||||
MinimumSize = new Size(700, 535);
|
||||
Text = "Voice Manager";
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||
MaximizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
|
||||
((System.ComponentModel.ISupportInitialize)gridVoices).EndInit();
|
||||
ResumeLayout(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using Robovoice.Core.Voices;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Robovoice.App;
|
||||
|
||||
internal sealed partial class VoiceManagerForm : Form
|
||||
{
|
||||
private readonly string _voicesDir;
|
||||
private readonly VoiceCatalogue _catalogue;
|
||||
private IReadOnlyList<VoiceInfo> _allVoices = Array.Empty<VoiceInfo>();
|
||||
private HashSet<string> _installedKeys = new();
|
||||
private bool _catalogueLoaded;
|
||||
|
||||
public string? SelectedVoiceKey { get; private set; }
|
||||
|
||||
public VoiceManagerForm(string voicesDir, string? currentVoiceKey)
|
||||
{
|
||||
InitializeComponent();
|
||||
_voicesDir = voicesDir;
|
||||
_catalogue = new VoiceCatalogue();
|
||||
SelectedVoiceKey = currentVoiceKey;
|
||||
|
||||
txtFilter.TextChanged += (_, _) => ApplyFilter();
|
||||
btnDownload.Click += OnDownload;
|
||||
btnRemove.Click += OnRemove;
|
||||
btnClose.Click += (_, _) => Close();
|
||||
gridVoices.SelectionChanged += OnSelectionChanged;
|
||||
Load += OnLoad;
|
||||
FormClosing += OnFormClosing;
|
||||
}
|
||||
|
||||
private async void OnLoad(object? sender, EventArgs e)
|
||||
{
|
||||
btnDownload.Enabled = false;
|
||||
btnRemove.Enabled = false;
|
||||
|
||||
RefreshInstalled();
|
||||
lblStatus.Text = "Fetching voice catalogue...";
|
||||
progressBar.Visible = true;
|
||||
progressBar.Style = ProgressBarStyle.Marquee;
|
||||
|
||||
try
|
||||
{
|
||||
_allVoices = await _catalogue.GetCatalogueAsync();
|
||||
_catalogueLoaded = true;
|
||||
PopulateGrid();
|
||||
lblStatus.Text = $"{_allVoices.Count} voices available, {_installedKeys.Count} installed";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lblStatus.Text = $"Failed to fetch catalogue: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
progressBar.Visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshInstalled()
|
||||
{
|
||||
_installedKeys = VoiceCatalogue
|
||||
.GetInstalledVoices(_voicesDir)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private void PopulateGrid()
|
||||
{
|
||||
gridVoices.Rows.Clear();
|
||||
gridVoices.Columns.Clear();
|
||||
|
||||
gridVoices.Columns.Add(new DataGridViewTextBoxColumn
|
||||
{
|
||||
Name = "Key",
|
||||
HeaderText = "Key",
|
||||
FillWeight = 40,
|
||||
});
|
||||
gridVoices.Columns.Add(new DataGridViewTextBoxColumn
|
||||
{
|
||||
Name = "Language",
|
||||
HeaderText = "Language",
|
||||
FillWeight = 25,
|
||||
});
|
||||
gridVoices.Columns.Add(new DataGridViewTextBoxColumn
|
||||
{
|
||||
Name = "Voice",
|
||||
HeaderText = "Voice",
|
||||
FillWeight = 20,
|
||||
});
|
||||
gridVoices.Columns.Add(new DataGridViewTextBoxColumn
|
||||
{
|
||||
Name = "Quality",
|
||||
HeaderText = "Quality",
|
||||
FillWeight = 15,
|
||||
});
|
||||
gridVoices.Columns.Add(new DataGridViewTextBoxColumn
|
||||
{
|
||||
Name = "Size",
|
||||
HeaderText = "Size",
|
||||
FillWeight = 15,
|
||||
});
|
||||
gridVoices.Columns.Add(new DataGridViewTextBoxColumn
|
||||
{
|
||||
Name = "Status",
|
||||
HeaderText = "Status",
|
||||
FillWeight = 15,
|
||||
});
|
||||
|
||||
foreach (var voice in _allVoices)
|
||||
{
|
||||
bool installed = _installedKeys.Contains(voice.Key);
|
||||
int rowIdx = gridVoices.Rows.Add(
|
||||
voice.Key,
|
||||
voice.Language.NameEnglish,
|
||||
voice.Name,
|
||||
voice.Quality,
|
||||
voice.SizeDisplay,
|
||||
installed ? "Installed" : "Available");
|
||||
|
||||
var row = gridVoices.Rows[rowIdx];
|
||||
row.Tag = voice.Key;
|
||||
if (installed)
|
||||
{
|
||||
row.DefaultCellStyle.BackColor = Color.FromArgb(235, 245, 235);
|
||||
if (voice.Key == SelectedVoiceKey)
|
||||
{
|
||||
row.DefaultCellStyle.Font = new Font(gridVoices.Font, FontStyle.Bold);
|
||||
row.Selected = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
row.DefaultCellStyle.ForeColor = Color.Gray;
|
||||
}
|
||||
}
|
||||
|
||||
if (gridVoices.SelectedRows.Count == 0 && gridVoices.Rows.Count > 0)
|
||||
gridVoices.Rows[0].Selected = true;
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
string filter = txtFilter.Text.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(filter))
|
||||
{
|
||||
foreach (DataGridViewRow row in gridVoices.Rows)
|
||||
row.Visible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (DataGridViewRow row in gridVoices.Rows)
|
||||
{
|
||||
if (row.Tag is not string key)
|
||||
{
|
||||
row.Visible = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var voice = _allVoices.FirstOrDefault(v => v.Key == key);
|
||||
if (voice is null)
|
||||
{
|
||||
row.Visible = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
string haystack = $"{key} {voice.Name} {voice.Language.NameEnglish} {voice.Language.Code} {voice.Language.Family} {voice.Quality}".ToLowerInvariant();
|
||||
row.Visible = haystack.Contains(filter);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (gridVoices.SelectedRows.Count == 0) return;
|
||||
var row = gridVoices.SelectedRows[0];
|
||||
if (row.Tag is not string key) return;
|
||||
|
||||
bool installed = _installedKeys.Contains(key);
|
||||
btnDownload.Enabled = !installed && _catalogueLoaded;
|
||||
btnRemove.Enabled = installed;
|
||||
}
|
||||
|
||||
private async void OnDownload(object? sender, EventArgs e)
|
||||
{
|
||||
if (gridVoices.SelectedRows.Count == 0) return;
|
||||
var row = gridVoices.SelectedRows[0];
|
||||
if (row.Tag is not string key) return;
|
||||
|
||||
var voice = _allVoices.FirstOrDefault(v => v.Key == key);
|
||||
if (voice is null) return;
|
||||
|
||||
btnDownload.Enabled = false;
|
||||
btnRemove.Enabled = false;
|
||||
progressBar.Visible = true;
|
||||
progressBar.Style = ProgressBarStyle.Continuous;
|
||||
progressBar.Value = 0;
|
||||
lblStatus.Text = $"Downloading {key} ({voice.SizeDisplay})...";
|
||||
|
||||
try
|
||||
{
|
||||
var progress = new Progress<(long downloaded, long total)>(p =>
|
||||
{
|
||||
if (p.total > 0)
|
||||
{
|
||||
progressBar.Value = (int)(p.downloaded * 100 / p.total);
|
||||
lblStatus.Text = $"Downloading {key}... {p.downloaded / (1024 * 1024)} / {p.total / (1024 * 1024)} MB";
|
||||
}
|
||||
});
|
||||
|
||||
await _catalogue.DownloadVoiceAsync(voice, _voicesDir, progress);
|
||||
RefreshInstalled();
|
||||
UpdateRowStatus(key, installed: true);
|
||||
lblStatus.Text = $"Downloaded {key} successfully.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lblStatus.Text = $"Download failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
progressBar.Visible = false;
|
||||
OnSelectionChanged(null, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRemove(object? sender, EventArgs e)
|
||||
{
|
||||
if (gridVoices.SelectedRows.Count == 0) return;
|
||||
var row = gridVoices.SelectedRows[0];
|
||||
if (row.Tag is not string key) return;
|
||||
|
||||
var dlgResult = MessageBox.Show(
|
||||
$"Remove voice '{key}'?\nThis will delete the .onnx and .onnx.json files.",
|
||||
"Confirm Remove",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question);
|
||||
|
||||
if (dlgResult != DialogResult.Yes) return;
|
||||
|
||||
try
|
||||
{
|
||||
VoiceCatalogue.RemoveVoice(_voicesDir, key);
|
||||
RefreshInstalled();
|
||||
UpdateRowStatus(key, installed: false);
|
||||
if (SelectedVoiceKey == key)
|
||||
SelectedVoiceKey = null;
|
||||
lblStatus.Text = $"Removed {key}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lblStatus.Text = $"Remove failed: {ex.Message}";
|
||||
}
|
||||
|
||||
OnSelectionChanged(null, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void UpdateRowStatus(string key, bool installed)
|
||||
{
|
||||
foreach (DataGridViewRow row in gridVoices.Rows)
|
||||
{
|
||||
if (row.Tag is string rowKey && rowKey == key)
|
||||
{
|
||||
row.Cells["Status"].Value = installed ? "Installed" : "Available";
|
||||
row.DefaultCellStyle.BackColor = installed
|
||||
? Color.FromArgb(235, 245, 235)
|
||||
: Color.White;
|
||||
row.DefaultCellStyle.ForeColor = installed
|
||||
? Color.Black
|
||||
: Color.Gray;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
lblStatus.Text = $"{_allVoices.Count} voices available, {_installedKeys.Count} installed";
|
||||
}
|
||||
|
||||
private void OnFormClosing(object? sender, FormClosingEventArgs e)
|
||||
{
|
||||
if (gridVoices.SelectedRows.Count > 0 && gridVoices.SelectedRows[0].Tag is string key)
|
||||
{
|
||||
if (_installedKeys.Contains(key))
|
||||
SelectedVoiceKey = key;
|
||||
}
|
||||
|
||||
_catalogue.DisposeAsync().AsTask().Wait();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user