Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using OpenClaw.Shared;
Expand All @@ -23,6 +24,10 @@ namespace OpenClawTray.Services;
/// </summary>
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;
Expand Down Expand Up @@ -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)");
}
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 48 additions & 0 deletions tests/OpenClaw.Tray.Tests/PiperTextToSpeechClientContractTests.cs
Original file line number Diff line number Diff line change
@@ -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}'.");
}
}
Loading