Files
Robovoice/Robovoice.App/VoiceManagerForm.cs
mute 8585972a1e 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
2026-08-10 11:27:28 +00:00

286 lines
8.7 KiB
C#

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();
}
}