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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user