Files
Robovoice/Robovoice.Tts.LibPiper/LibPiperTtsEngine.cs
T
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

180 lines
5.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}