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:
2026-08-10 11:27:28 +00:00
parent 03e6ccf388
commit 8585972a1e
25 changed files with 2005 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
## .NET build output
bin/
obj/
## NuGet
*.nupkg
*.snupkg
.nuget/
## User-specific files
*.user
*.suo
.vs/
.idea/
## Test project (scratch)
Robovoice.Test/
## Native binaries (built from piper1-gpl, not source)
Robovoice.Tts.LibPiper/runtimes/
Robovoice.Tts.LibPiper/piper.h
## Voice models (downloaded at runtime)
*.onnx
*.onnx.json
## Temp files
*.wav
*.raw
+112
View File
@@ -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;
}
}
+193
View File
@@ -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);
}
}
+355
View File
@@ -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();
}
}
+120
View File
@@ -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;
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Robovoice.App;
static class Program
{
[STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}
+94
View File
@@ -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);
}
+22
View File
@@ -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
View File
@@ -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);
}
}
+285
View File
@@ -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();
}
}
+3
View File
@@ -0,0 +1,3 @@
namespace Robovoice.Core;
public sealed record AudioChunk(float[] Samples, int SampleRate);
+15
View File
@@ -0,0 +1,15 @@
namespace Robovoice.Core;
public interface ISttSource : IAsyncDisposable
{
Task StartAsync(CancellationToken ct = default);
Task StopAsync(CancellationToken ct = default);
}
public sealed class TranscriptEventArgs : EventArgs
{
public TranscriptMessage Message { get; init; } = new(TranscriptType.Final, string.Empty);
}
public delegate void TranscriptEventHandler(object? sender, TranscriptEventArgs e);
+27
View File
@@ -0,0 +1,27 @@
using System.Runtime.CompilerServices;
namespace Robovoice.Core;
public interface ITtsEngine : IAsyncDisposable
{
string Name { get; }
int SampleRate { get; }
Task InitializeAsync(CancellationToken ct = default);
IAsyncEnumerable<AudioChunk> SynthesizeAsync(
string text,
CancellationToken ct = default);
}
public static class TtsEngineExtensions
{
public static ConfiguredCancelableAsyncEnumerable<AudioChunk> SynthesizeAsync(
this ITtsEngine engine,
string text,
CancellationToken ct = default)
{
return engine.SynthesizeAsync(text, ct).ConfigureAwait(false).WithCancellation(ct);
}
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+9
View File
@@ -0,0 +1,9 @@
namespace Robovoice.Core;
public enum TranscriptType
{
Partial,
Final,
}
public sealed record TranscriptMessage(TranscriptType Type, string Text);
+131
View File
@@ -0,0 +1,131 @@
using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
namespace Robovoice.Core.Voices;
public sealed class VoiceCatalogue : IAsyncDisposable
{
private const string CatalogueUrl =
"https://huggingface.co/rhasspy/piper-voices/resolve/main/voices.json";
private const string DownloadBaseUrl =
"https://huggingface.co/rhasspy/piper-voices/resolve/main/";
private readonly HttpClient _http;
private List<VoiceInfo>? _catalogue;
private bool _disposed;
public VoiceCatalogue()
{
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
}
public async Task<IReadOnlyList<VoiceInfo>> GetCatalogueAsync(
CancellationToken ct = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_catalogue is not null)
return _catalogue;
var dict = await _http.GetFromJsonAsync<Dictionary<string, VoiceInfo>>(
CatalogueUrl, ct)
?? throw new InvalidOperationException("Failed to fetch voice catalogue.");
_catalogue = dict.Values
.OrderBy(v => v.Language.NameEnglish)
.ThenBy(v => v.Name)
.ThenBy(v => v.Quality)
.ToList();
return _catalogue;
}
public static IReadOnlyList<string> GetInstalledVoices(string voicesDir)
{
if (!Directory.Exists(voicesDir))
return Array.Empty<string>();
return Directory.GetFiles(voicesDir, "*.onnx")
.Select(f => Path.GetFileNameWithoutExtension(f)!)
.OrderBy(n => n)
.ToList();
}
public static bool IsVoiceInstalled(string voicesDir, string voiceKey)
{
string onnxPath = Path.Combine(voicesDir, voiceKey + ".onnx");
string jsonPath = Path.Combine(voicesDir, voiceKey + ".onnx.json");
return File.Exists(onnxPath) && File.Exists(jsonPath);
}
public async Task DownloadVoiceAsync(
VoiceInfo voice,
string voicesDir,
IProgress<(long downloaded, long total)>? progress = null,
CancellationToken ct = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Directory.CreateDirectory(voicesDir);
var onnxFile = voice.Files.GetValueOrDefault(voice.OnnxFileKey)
?? throw new InvalidOperationException($"No .onnx file for {voice.Key}");
var jsonFile = voice.Files.GetValueOrDefault(voice.JsonFileKey)
?? throw new InvalidOperationException($"No .onnx.json file for {voice.Key}");
string onnxPath = Path.Combine(voicesDir, voice.Key + ".onnx");
string jsonPath = Path.Combine(voicesDir, voice.Key + ".onnx.json");
await DownloadFileAsync(
DownloadBaseUrl + voice.OnnxFileKey,
onnxPath, onnxFile.SizeBytes, onnxFile.Md5Digest, progress, ct);
await DownloadFileAsync(
DownloadBaseUrl + voice.JsonFileKey,
jsonPath, jsonFile.SizeBytes, jsonFile.Md5Digest, null, ct);
}
private async Task DownloadFileAsync(
string url,
string destPath,
long expectedSize,
string expectedMd5,
IProgress<(long, long)>? progress,
CancellationToken ct)
{
using var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
long total = resp.Content.Headers.ContentLength ?? expectedSize;
long downloaded = 0;
await using var contentStream = await resp.Content.ReadAsStreamAsync(ct);
await using var fileStream = File.Create(destPath);
byte[] buffer = new byte[81920];
int read;
while ((read = await contentStream.ReadAsync(buffer, ct)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, read), ct);
downloaded += read;
progress?.Report((downloaded, total));
}
}
public static void RemoveVoice(string voicesDir, string voiceKey)
{
string onnxPath = Path.Combine(voicesDir, voiceKey + ".onnx");
string jsonPath = Path.Combine(voicesDir, voiceKey + ".onnx.json");
if (File.Exists(onnxPath)) File.Delete(onnxPath);
if (File.Exists(jsonPath)) File.Delete(jsonPath);
}
public ValueTask DisposeAsync()
{
if (_disposed) return ValueTask.CompletedTask;
_http.Dispose();
_disposed = true;
return ValueTask.CompletedTask;
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Text.Json.Serialization;
namespace Robovoice.Core.Voices;
public sealed class VoiceInfo
{
public string Key { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public VoiceLanguage Language { get; set; } = new();
public string Quality { get; set; } = string.Empty;
public int NumSpeakers { get; set; }
public VoiceFiles Files { get; set; } = new();
public List<string> Aliases { get; set; } = new();
[JsonIgnore]
public string DisplayName =>
$"{Language.NameEnglish} ({Language.Code}) — {Name} [{Quality}]";
[JsonIgnore]
public long SizeBytes =>
Files.FirstOrDefault(f => f.Key.EndsWith(".onnx")).Value?.SizeBytes ?? 0;
[JsonIgnore]
public string SizeDisplay
{
get
{
double mb = SizeBytes / (1024.0 * 1024.0);
return mb >= 1024 ? $"{mb / 1024:F1} GB" : $"{mb:F0} MB";
}
}
[JsonIgnore]
public string OnnxFileKey => Files.Keys.FirstOrDefault(k => k.EndsWith(".onnx")) ?? "";
[JsonIgnore]
public string JsonFileKey => Files.Keys.FirstOrDefault(k => k.EndsWith(".onnx.json")) ?? "";
}
public sealed class VoiceLanguage
{
public string Code { get; set; } = string.Empty;
public string Family { get; set; } = string.Empty;
public string Region { get; set; } = string.Empty;
[JsonPropertyName("name_native")]
public string NameNative { get; set; } = string.Empty;
[JsonPropertyName("name_english")]
public string NameEnglish { get; set; } = string.Empty;
[JsonPropertyName("country_english")]
public string CountryEnglish { get; set; } = string.Empty;
}
public sealed class VoiceFiles : Dictionary<string, VoiceFile> { }
public sealed class VoiceFile
{
[JsonPropertyName("size_bytes")]
public long SizeBytes { get; set; }
[JsonPropertyName("md5_digest")]
public string Md5Digest { get; set; } = string.Empty;
}
+83
View File
@@ -0,0 +1,83 @@
using Robovoice.Core;
namespace Robovoice.Stt.File;
public sealed class FileSttSource : ISttSource
{
private readonly object _lock = new();
private string[] _lines = Array.Empty<string>();
private int _currentIndex;
private bool _disposed;
public event TranscriptEventHandler? TranscriptReceived;
public string FilePath { get; set; } = string.Empty;
public int LineCount { get; private set; }
public int CurrentIndex => _currentIndex;
public void LoadFile(string path)
{
ObjectDisposedException.ThrowIf(_disposed, this);
FilePath = path;
_lines = System.IO.File.ReadAllLines(path);
LineCount = _lines.Length;
_currentIndex = 0;
}
public string? GetPendingLine()
{
lock (_lock)
{
if (_lines.Length == 0) return null;
int idx = _currentIndex % _lines.Length;
return _lines[idx];
}
}
public void EmitNext()
{
ObjectDisposedException.ThrowIf(_disposed, this);
string? line = null;
lock (_lock)
{
if (_lines.Length > 0)
{
int idx = _currentIndex % _lines.Length;
line = _lines[idx];
_currentIndex++;
}
}
if (!string.IsNullOrWhiteSpace(line))
{
TranscriptReceived?.Invoke(this, new TranscriptEventArgs
{
Message = new TranscriptMessage(TranscriptType.Final, line!),
});
}
}
public void Reset()
{
lock (_lock)
{
_currentIndex = 0;
}
}
public Task StartAsync(CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct = default)
{
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
_disposed = true;
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Robovoice.Core\Robovoice.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Robovoice.Core\Robovoice.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+179
View File
@@ -0,0 +1,179 @@
using System.Runtime.InteropServices;
using Robovoice.Core;
namespace Robovoice.Tts.LibPiper;
public sealed class LibPiperTtsEngine : ITtsEngine
{
private readonly string _modelPath;
private readonly string _espeakDataPath;
private readonly float? _noiseScaleOverride;
private readonly float? _lengthScaleOverride;
private readonly float? _noiseWScaleOverride;
private float _noiseScale;
private float _lengthScale;
private float _noiseWScale;
private IntPtr _synth;
private int _sampleRate;
private bool _initialized;
private bool _disposed;
public string Name => "libpiper";
public int SampleRate => _sampleRate;
public LibPiperTtsEngine(
string modelPath,
string espeakDataPath,
float? noiseScale = null,
float? lengthScale = null,
float? noiseWScale = null)
{
_modelPath = modelPath;
_espeakDataPath = espeakDataPath;
_noiseScaleOverride = noiseScale;
_lengthScaleOverride = lengthScale;
_noiseWScaleOverride = noiseWScale;
_noiseScale = noiseScale ?? 0.667f;
_lengthScale = lengthScale ?? 1.0f;
_noiseWScale = noiseWScale ?? 0.8f;
}
public Task InitializeAsync(CancellationToken ct = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_initialized)
return Task.CompletedTask;
NativeDependencyLoader.EnsureLoaded();
_synth = PiperCreateUtf8(_modelPath, _modelPath + ".json", _espeakDataPath);
if (_synth == IntPtr.Zero)
throw new InvalidOperationException(
$"piper_create failed for model: {_modelPath}");
var defaults = PiperNative.piper_default_synthesize_options(_synth);
_noiseScale = _noiseScaleOverride ?? defaults.NoiseScale;
_lengthScale = _lengthScaleOverride ?? defaults.LengthScale;
_noiseWScale = _noiseWScaleOverride ?? defaults.NoiseWScale;
_sampleRate = 22050;
_initialized = true;
return Task.CompletedTask;
}
public async IAsyncEnumerable<AudioChunk> SynthesizeAsync(
string text,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_initialized)
throw new InvalidOperationException("Engine not initialized.");
var options = new PiperSynthesizeOptions
{
SpeakerId = 0,
LengthScale = _lengthScale,
NoiseScale = _noiseScale,
NoiseWScale = _noiseWScale,
};
byte[] textBytes = System.Text.Encoding.UTF8.GetBytes(EnsureTerminator(text) + "\0");
GCHandle textPin = GCHandle.Alloc(textBytes, GCHandleType.Pinned);
try
{
int startResult = PiperNative.piper_synthesize_start(
_synth, textPin.AddrOfPinnedObject(), in options);
if (startResult != PiperNative.PiperOk)
throw new InvalidOperationException($"piper_synthesize_start failed: {startResult}");
while (true)
{
ct.ThrowIfCancellationRequested();
PiperAudioChunk chunk = default;
int result = await Task.Run(() => PiperNative.piper_synthesize_next(_synth, out chunk), ct);
if (chunk.NumSamples > 0 && chunk.Samples != IntPtr.Zero)
{
int numSamples = (int)chunk.NumSamples;
float[] samples = new float[numSamples];
Marshal.Copy(chunk.Samples, samples, 0, numSamples);
if (chunk.SampleRate > 0)
_sampleRate = chunk.SampleRate;
yield return new AudioChunk(samples, _sampleRate);
}
if (result == PiperNative.PiperDone || chunk.IsLast)
break;
if (result < 0)
throw new InvalidOperationException($"piper_synthesize_next failed: {result}");
}
}
finally
{
textPin.Free();
}
}
private static string EnsureTerminator(string text)
{
string trimmed = text.TrimEnd();
if (trimmed.Length == 0)
return text;
char last = trimmed[^1];
if (last is '.' or '!' or '?' or ',' or ';' or ':' or ')' or ']' or '}' or '"' or '\'' or '。' or '' or '')
return text;
return trimmed + ".";
}
private static IntPtr PiperCreateUtf8(string modelPath, string? configPath, string espeakDataPath)
{
byte[] modelBytes = System.Text.Encoding.UTF8.GetBytes(modelPath + "\0");
byte[] espeakBytes = System.Text.Encoding.UTF8.GetBytes(espeakDataPath + "\0");
GCHandle modelPin = GCHandle.Alloc(modelBytes, GCHandleType.Pinned);
GCHandle espeakPin = GCHandle.Alloc(espeakBytes, GCHandleType.Pinned);
GCHandle? configPin = null;
byte[]? configBytes = null;
if (configPath is not null)
{
configBytes = System.Text.Encoding.UTF8.GetBytes(configPath + "\0");
configPin = GCHandle.Alloc(configBytes, GCHandleType.Pinned);
}
try
{
return PiperNative.piper_create(
modelPin.AddrOfPinnedObject(),
configPin?.AddrOfPinnedObject() ?? IntPtr.Zero,
espeakPin.AddrOfPinnedObject());
}
finally
{
modelPin.Free();
espeakPin.Free();
configPin?.Free();
}
}
public ValueTask DisposeAsync()
{
if (_disposed)
return ValueTask.CompletedTask;
if (_synth != IntPtr.Zero)
{
PiperNative.piper_free(_synth);
_synth = IntPtr.Zero;
}
_disposed = true;
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,28 @@
using System.Runtime.InteropServices;
namespace Robovoice.Tts.LibPiper;
internal static class NativeDependencyLoader
{
private static int _loaded;
public static void EnsureLoaded()
{
if (Interlocked.CompareExchange(ref _loaded, 1, 0) != 0)
return;
string baseDir = AppContext.BaseDirectory;
string nativeDir = Path.Combine(baseDir, "runtimes", "win-x64", "native");
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Directory.Exists(nativeDir))
{
if (!SetDllDirectory(nativeDir))
throw new InvalidOperationException(
$"SetDllDirectory failed for: {nativeDir}");
}
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetDllDirectory(string lpPathName);
}
+65
View File
@@ -0,0 +1,65 @@
using System.Runtime.InteropServices;
namespace Robovoice.Tts.LibPiper;
[StructLayout(LayoutKind.Sequential)]
internal struct PiperSynthesizeOptions
{
public int SpeakerId;
public float LengthScale;
public float NoiseScale;
public float NoiseWScale;
}
[StructLayout(LayoutKind.Sequential)]
internal struct PiperAudioChunk
{
public IntPtr Samples;
public nuint NumSamples;
public int SampleRate;
[MarshalAs(UnmanagedType.U1)]
public bool IsLast;
public IntPtr Phonemes;
public nuint NumPhonemes;
public IntPtr PhonemeIds;
public nuint NumPhonemeIds;
public IntPtr Alignments;
public nuint NumAlignments;
}
internal static class PiperNative
{
private const string LibName = "piper";
public const int PiperOk = 0;
public const int PiperDone = 1;
public const int PiperErrGeneric = -1;
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr piper_create(
IntPtr modelPath,
IntPtr configPath,
IntPtr espeakDataPath);
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
public static extern void piper_free(IntPtr synth);
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
public static extern PiperSynthesizeOptions piper_default_synthesize_options(IntPtr synth);
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
public static extern int piper_synthesize_start(
IntPtr synth,
IntPtr text,
in PiperSynthesizeOptions options);
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
public static extern int piper_synthesize_next(
IntPtr synth,
out PiperAudioChunk chunk);
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr piper_version();
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Robovoice.Core\Robovoice.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<None Include="runtimes\win-x64\native\piper.dll" CopyToOutputDirectory="PreserveNewest" Link="piper.dll" />
<None Include="runtimes\win-x64\native\onnxruntime.dll" CopyToOutputDirectory="PreserveNewest" Link="onnxruntime.dll" />
<None Include="runtimes\win-x64\native\onnxruntime_providers_shared.dll" CopyToOutputDirectory="PreserveNewest" Link="onnxruntime_providers_shared.dll" />
<None Include="runtimes\win-x64\native\espeak-ng-data\**\*" CopyToOutputDirectory="PreserveNewest" Link="espeak-ng-data\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
</Project>
+7
View File
@@ -0,0 +1,7 @@
<Solution>
<Project Path="Robovoice.App/Robovoice.App.csproj" />
<Project Path="Robovoice.Core/Robovoice.Core.csproj" />
<Project Path="Robovoice.Stt.File/Robovoice.Stt.File.csproj" />
<Project Path="Robovoice.Stt.Udp/Robovoice.Stt.Udp.csproj" />
<Project Path="Robovoice.Tts.LibPiper/Robovoice.Tts.LibPiper.csproj" />
</Solution>