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,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