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
+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;
}
}