diff --git a/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs index 2d595ba61..54f5e8a65 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using OpenClaw.Shared; @@ -23,6 +24,10 @@ namespace OpenClawTray.Services; /// public sealed class PiperTextToSpeechClient : IDisposable { + private const string SherpaNativeLibrary = "sherpa-onnx-c-api"; + private static readonly object s_nativeLibraryLock = new(); + private static IntPtr s_nativeLibraryHandle; + private readonly IOpenClawLogger _logger; private readonly string _voiceId; private readonly OfflineTts _tts; @@ -53,6 +58,7 @@ public PiperTextToSpeechClient(IOpenClawLogger logger, PiperVoiceManager voices, config.Model.Debug = 0; config.MaxNumSentences = 2; + EnsureNativeLibraryLoaded(); _tts = new OfflineTts(config); _logger.Info($"Piper voice '{_voiceId}' loaded (sample rate {_tts.SampleRate} Hz, {config.Model.NumThreads} threads)"); } @@ -126,10 +132,31 @@ private static byte[] ConvertFloatPcmToWav(float[] samples, int sampleRate) return ms.ToArray(); } + private static void EnsureNativeLibraryLoaded() + { + lock (s_nativeLibraryLock) + { + if (s_nativeLibraryHandle != IntPtr.Zero) + return; + + if (!NativeLibrary.TryLoad( + SherpaNativeLibrary, + typeof(OfflineTts).Assembly, + DllImportSearchPath.SafeDirectories, + out s_nativeLibraryHandle)) + { + throw new DllNotFoundException("Piper TTS native library could not be loaded."); + } + } + } + public void Dispose() { if (_disposed) return; _disposed = true; + // SherpaOnnx suppresses its finalizer only after native cleanup succeeds. + // Suppress first so a cleanup failure cannot retry from the finalizer thread. + GC.SuppressFinalize(_tts); // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. try { _tts.Dispose(); } catch { /* swallow */ } // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. diff --git a/tests/OpenClaw.Tray.Tests/PiperTextToSpeechClientContractTests.cs b/tests/OpenClaw.Tray.Tests/PiperTextToSpeechClientContractTests.cs new file mode 100644 index 000000000..ca7ae6629 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/PiperTextToSpeechClientContractTests.cs @@ -0,0 +1,48 @@ +namespace OpenClaw.Tray.Tests; + +public sealed class PiperTextToSpeechClientContractTests +{ + private static string ReadSource() => + File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Services", + "TextToSpeech", + "PiperTextToSpeechClient.cs")); + + [Fact] + public void NativeLibrary_IsLoadedBeforeFinalizableWrapperConstruction() + { + var source = ReadSource(); + + AssertInOrder( + source, + "EnsureNativeLibraryLoaded();", + "_tts = new OfflineTts(config);"); + Assert.Contains("NativeLibrary.TryLoad(", source); + Assert.Contains("typeof(OfflineTts).Assembly", source); + Assert.Contains("DllImportSearchPath.SafeDirectories", source); + Assert.Contains("out s_nativeLibraryHandle", source); + } + + [Fact] + public void Dispose_SuppressesFinalizerBeforeNativeCleanup() + { + var source = ReadSource(); + + AssertInOrder( + source, + "GC.SuppressFinalize(_tts);", + "_tts.Dispose();"); + } + + private static void AssertInOrder(string source, string first, string second) + { + var firstIndex = source.IndexOf(first, StringComparison.Ordinal); + var secondIndex = source.IndexOf(second, StringComparison.Ordinal); + + Assert.True(firstIndex >= 0, $"Missing expected source fragment: {first}"); + Assert.True(secondIndex > firstIndex, $"Expected '{first}' before '{second}'."); + } +}