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 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(); } } public IEnumerable SynthesizeSync(string text) { 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) { PiperAudioChunk chunk = default; int result = PiperNative.piper_synthesize_next(_synth, out chunk); 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(); } } public void SynthesizeDrain() { if (!_initialized || _synth == IntPtr.Zero) return; PiperAudioChunk chunk; while (PiperNative.piper_synthesize_next(_synth, out chunk) != PiperNative.PiperDone) { if (chunk.IsLast) break; } } 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; } }