using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
namespace RustTtsWrapper;
public static class Native
{
private const string Lib = "rust_tts_wrapper";
static Native()
{
string? explicitPath = Environment.GetEnvironmentVariable("TTS_WRAPPER_LIB");
if (!string.IsNullOrEmpty(explicitPath))
{
if (!File.Exists(explicitPath))
throw new DllNotFoundException($"TTS_WRAPPER_LIB points to a missing file: {explicitPath}");
IntPtr handle = NativeLibrary.Load(explicitPath);
NativeLibrary.SetDllImportResolver(typeof(Native).Assembly, (name, _, _) =>
name == Lib ? handle : IntPtr.Zero);
}
}
[DllImport(Lib)] public static extern IntPtr tts_create(string engineId, string credentialsJson);
[DllImport(Lib)] public static extern void tts_destroy(IntPtr ctx);
[DllImport(Lib)] public static extern int tts_speak(IntPtr ctx, string text);
[DllImport(Lib)] public static extern int tts_speak_sync(IntPtr ctx, string text);
[DllImport(Lib)] public static extern void tts_stop(IntPtr ctx);
[DllImport(Lib)] public static extern void tts_pause(IntPtr ctx);
[DllImport(Lib)] public static extern void tts_resume(IntPtr ctx);
[DllImport(Lib)] public static extern int tts_synth_to_bytes(IntPtr ctx, string text, out IntPtr outBytes, out UIntPtr outLen);
[DllImport(Lib)] public static extern void tts_free_bytes(IntPtr bytes, UIntPtr len);
[DllImport(Lib)] public static extern void tts_set_voice(IntPtr ctx, string voiceId);
[DllImport(Lib)] public static extern void tts_set_rate(IntPtr ctx, float rate);
[DllImport(Lib)] public static extern void tts_set_pitch(IntPtr ctx, float pitch);
[DllImport(Lib)] public static extern void tts_set_volume(IntPtr ctx, float volume);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void AudioCallbackNative(IntPtr bytes, UIntPtr len, IntPtr userdata);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void BoundaryCallbackNative(IntPtr word, int charOffset, int charLen,
float start, float end, int estimated, IntPtr userdata);
[DllImport(Lib)] public static extern void tts_set_on_audio(IntPtr ctx, AudioCallbackNative? cb, IntPtr userdata);
[DllImport(Lib)] public static extern void tts_set_on_boundary(IntPtr ctx, BoundaryCallbackNative? cb, IntPtr userdata);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void MarkCallbackNative(IntPtr name, int charOffset, float start, float end, IntPtr userdata);
[DllImport(Lib)] public static extern void tts_set_on_mark(IntPtr ctx, MarkCallbackNative? cb, IntPtr userdata);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void VisemeCallbackNative(int visemeId, float offsetSec, IntPtr userdata);
[DllImport(Lib)] public static extern void tts_set_on_viseme(IntPtr ctx, VisemeCallbackNative? cb, IntPtr userdata);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void VoidCallbackNative(IntPtr userdata);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void ErrorCallbackNative(IntPtr error, IntPtr userdata);
[DllImport(Lib)] public static extern void tts_set_on_start(IntPtr ctx, VoidCallbackNative? cb, IntPtr userdata);
[DllImport(Lib)] public static extern void tts_set_on_end(IntPtr ctx, VoidCallbackNative? cb, IntPtr userdata);
[DllImport(Lib)] public static extern void tts_set_on_error(IntPtr ctx, ErrorCallbackNative? cb, IntPtr userdata);
[DllImport(Lib)] public static extern int tts_get_voices(IntPtr ctx, out IntPtr voices, out int count);
[DllImport(Lib)] public static extern void tts_free_voices(IntPtr voices, int count);
[DllImport(Lib)] public static extern int tts_get_engine_count();
[DllImport(Lib)] public static extern int tts_get_engines(out IntPtr engines, out int count);
[DllImport(Lib)] public static extern void tts_free_engines(IntPtr engines, int count);
[DllImport(Lib)] public static extern IntPtr tts_get_last_error(IntPtr ctx);
}
[StructLayout(LayoutKind.Sequential)]
public struct TtsVoiceNative
{
public IntPtr Id;
public IntPtr Name;
public IntPtr Language;
public IntPtr Gender;
public IntPtr Engine;
}
[StructLayout(LayoutKind.Sequential)]
public struct TtsEngineInfoNative
{
public IntPtr Id;
public IntPtr Name;
[MarshalAs(UnmanagedType.U1)] public bool NeedsCredentials;
public IntPtr CredentialKeysJson;
}
public sealed class TtsVoice
{
public string Id { get; }
public string Name { get; }
public string Language { get; }
public string Gender { get; }
public string Engine { get; }
public TtsVoice(string id, string name, string language, string gender, string engine)
{
Id = id; Name = name; Language = language; Gender = gender; Engine = engine;
}
public override string ToString() => $"{Name} ({Id}) [{Engine}/{Language}]";
}
public sealed class TtsEngineInfo
{
public string Id { get; }
public string Name { get; }
public bool NeedsCredentials { get; }
public string CredentialKeysJson { get; }
public TtsEngineInfo(string id, string name, bool needsCredentials, string credentialKeysJson)
{
Id = id; Name = name; NeedsCredentials = needsCredentials; CredentialKeysJson = credentialKeysJson;
}
public List<string> CredentialKeys
{
get
{
try
{
return JsonSerializer.Deserialize<List<string>>(CredentialKeysJson ?? "[]") ?? new();
}
catch
{
return new();
}
}
}
public override string ToString() => $"{Name} ({Id})";
}
public delegate void AudioCallback(byte[] chunk);
public delegate void BoundaryCallback(string word, int charOffset, int charLen,
float startTime, float endTime, bool estimated);
public delegate void MarkCallback(string name, int charOffset, float startTime, float endTime);
public delegate void VisemeCallback(int visemeId, float offsetSec);
public delegate void LifecycleCallback();
public delegate void ErrorCallback(string error);
public class TtsException : Exception
{
public TtsException(string message) : base(message) { }
}
public class TtsClient : IDisposable
{
private IntPtr _ctx;
private bool _disposed;
private Native.AudioCallbackNative? _audioNative;
private Native.BoundaryCallbackNative? _boundaryNative;
private Native.MarkCallbackNative? _markNative;
private Native.VisemeCallbackNative? _visemeNative;
private Native.VoidCallbackNative? _startNative;
private Native.VoidCallbackNative? _endNative;
private Native.ErrorCallbackNative? _errorNative;
public TtsClient(string engineId = "system", Dictionary<string, string>? credentials = null)
{
var json = JsonSerializer.Serialize(credentials ?? new Dictionary<string, string>());
_ctx = Native.tts_create(engineId, json);
if (_ctx == IntPtr.Zero)
throw new TtsException($"Failed to create engine '{engineId}': {GetGlobalLastError()}");
}
public void Speak(string text)
{
ThrowIfDisposed();
if (Native.tts_speak(_ctx, text) != 0)
throw new TtsException(GetLastError() ?? "unknown error");
}
public void SpeakSync(string text)
{
ThrowIfDisposed();
if (Native.tts_speak_sync(_ctx, text) != 0)
throw new TtsException(GetLastError() ?? "unknown error");
}
public byte[] SynthToBytes(string text)
{
ThrowIfDisposed();
IntPtr bufPtr;
UIntPtr len;
if (Native.tts_synth_to_bytes(_ctx, text, out bufPtr, out len) != 0)
throw new TtsException(GetLastError() ?? "unknown error");
long length = (long)len;
if (length > int.MaxValue)
throw new TtsException("Synthesised buffer exceeds 2 GiB");
if (length == 0 || bufPtr == IntPtr.Zero) return Array.Empty<byte>();
byte[] data = new byte[length];
Marshal.Copy(bufPtr, data, 0, (int)length);
Native.tts_free_bytes(bufPtr, len);
return data;
}
public void Stop() { ThrowIfDisposed(); Native.tts_stop(_ctx); }
public void Pause() { ThrowIfDisposed(); Native.tts_pause(_ctx); }
public void Resume() { ThrowIfDisposed(); Native.tts_resume(_ctx); }
public void SetVoice(string voiceId) { ThrowIfDisposed(); Native.tts_set_voice(_ctx, voiceId); }
public void SetRate(float rate) { ThrowIfDisposed(); Native.tts_set_rate(_ctx, rate); }
public void SetPitch(float pitch) { ThrowIfDisposed(); Native.tts_set_pitch(_ctx, pitch); }
public void SetVolume(float volume) { ThrowIfDisposed(); Native.tts_set_volume(_ctx, volume); }
public void SetOnAudio(AudioCallback? callback)
{
ThrowIfDisposed();
if (callback == null)
{
_audioNative = null;
Native.tts_set_on_audio(_ctx, null, IntPtr.Zero);
return;
}
_audioNative = (IntPtr bytes, UIntPtr len, IntPtr _userdata) =>
{
long n = (long)len;
if (n == 0 || bytes == IntPtr.Zero)
{
callback(Array.Empty<byte>());
return;
}
byte[] chunk = new byte[Math.Min(n, int.MaxValue)];
Marshal.Copy(bytes, chunk, 0, chunk.Length);
callback(chunk);
};
Native.tts_set_on_audio(_ctx, _audioNative, IntPtr.Zero);
}
public void SetOnBoundary(BoundaryCallback? callback)
{
ThrowIfDisposed();
if (callback == null)
{
_boundaryNative = null;
Native.tts_set_on_boundary(_ctx, null, IntPtr.Zero);
return;
}
_boundaryNative = (IntPtr wordPtr, int charOffset, int charLen,
float start, float end, int estimated, IntPtr _userdata) =>
{
string word = wordPtr == IntPtr.Zero ? "" : Marshal.PtrToStringAnsi(wordPtr) ?? "";
callback(word, charOffset, charLen, start, end, estimated != 0);
};
Native.tts_set_on_boundary(_ctx, _boundaryNative, IntPtr.Zero);
}
public void SetOnMark(MarkCallback? callback)
{
ThrowIfDisposed();
if (callback == null)
{
_markNative = null;
Native.tts_set_on_mark(_ctx, null, IntPtr.Zero);
return;
}
_markNative = (IntPtr namePtr, int charOffset, float start, float end, IntPtr _userdata) =>
{
string name = namePtr == IntPtr.Zero ? "" : Marshal.PtrToStringAnsi(namePtr) ?? "";
callback(name, charOffset, start, end);
};
Native.tts_set_on_mark(_ctx, _markNative, IntPtr.Zero);
}
public void SetOnViseme(VisemeCallback? callback)
{
ThrowIfDisposed();
if (callback == null)
{
_visemeNative = null;
Native.tts_set_on_viseme(_ctx, null, IntPtr.Zero);
return;
}
_visemeNative = (int visemeId, float offsetSec, IntPtr _userdata) =>
callback(visemeId, offsetSec);
Native.tts_set_on_viseme(_ctx, _visemeNative, IntPtr.Zero);
}
public void SetOnStart(LifecycleCallback? callback)
{
ThrowIfDisposed();
if (callback == null)
{
_startNative = null;
Native.tts_set_on_start(_ctx, null, IntPtr.Zero);
return;
}
_startNative = (IntPtr _userdata) => callback();
Native.tts_set_on_start(_ctx, _startNative, IntPtr.Zero);
}
public void SetOnEnd(LifecycleCallback? callback)
{
ThrowIfDisposed();
if (callback == null)
{
_endNative = null;
Native.tts_set_on_end(_ctx, null, IntPtr.Zero);
return;
}
_endNative = (IntPtr _userdata) => callback();
Native.tts_set_on_end(_ctx, _endNative, IntPtr.Zero);
}
public void SetOnError(ErrorCallback? callback)
{
ThrowIfDisposed();
if (callback == null)
{
_errorNative = null;
Native.tts_set_on_error(_ctx, null, IntPtr.Zero);
return;
}
_errorNative = (IntPtr errorPtr, IntPtr _userdata) =>
{
string msg = errorPtr == IntPtr.Zero ? "unknown error" : Marshal.PtrToStringAnsi(errorPtr) ?? "unknown error";
callback(msg);
};
Native.tts_set_on_error(_ctx, _errorNative, IntPtr.Zero);
}
public List<TtsVoice> GetVoices()
{
ThrowIfDisposed();
if (Native.tts_get_voices(_ctx, out IntPtr arr, out int count) != 0)
throw new TtsException(GetLastError() ?? "unknown error");
return MarshalVoiceArray(arr, count);
}
public static List<TtsEngineInfo> ListEngines()
{
if (Native.tts_get_engines(out IntPtr arr, out int count) != 0)
throw new TtsException(GetGlobalLastError() ?? "unknown error");
return MarshalEngineArray(arr, count);
}
public static int EngineCount() => Native.tts_get_engine_count();
public string? GetLastError()
{
if (_ctx == IntPtr.Zero) return null;
IntPtr ptr = Native.tts_get_last_error(_ctx);
return ptr == IntPtr.Zero ? null : Marshal.PtrToStringAnsi(ptr);
}
public static string? GetGlobalLastError()
{
IntPtr ptr = Native.tts_get_last_error(IntPtr.Zero);
return ptr == IntPtr.Zero ? null : Marshal.PtrToStringAnsi(ptr);
}
public void Dispose()
{
if (_disposed) return;
if (_ctx != IntPtr.Zero)
{
Native.tts_destroy(_ctx);
_ctx = IntPtr.Zero;
}
_disposed = true;
GC.SuppressFinalize(this);
}
~TtsClient() => Dispose();
private void ThrowIfDisposed()
{
if (_disposed || _ctx == IntPtr.Zero)
throw new ObjectDisposedException(nameof(TtsClient));
}
private static List<TtsVoice> MarshalVoiceArray(IntPtr arr, int count)
{
var list = new List<TtsVoice>(count);
if (arr == IntPtr.Zero || count <= 0) return list;
int structSize = Marshal.SizeOf<TtsVoiceNative>();
for (int i = 0; i < count; i++)
{
IntPtr item = IntPtr.Add(arr, i * structSize);
var native = Marshal.PtrToStructure<TtsVoiceNative>(item);
list.Add(new TtsVoice(
PtrToUtf8(native.Id),
PtrToUtf8(native.Name),
PtrToUtf8(native.Language),
PtrToUtf8(native.Gender),
PtrToUtf8(native.Engine)));
}
Native.tts_free_voices(arr, count);
return list;
}
private static List<TtsEngineInfo> MarshalEngineArray(IntPtr arr, int count)
{
var list = new List<TtsEngineInfo>(count);
if (arr == IntPtr.Zero || count <= 0) return list;
int structSize = Marshal.SizeOf<TtsEngineInfoNative>();
for (int i = 0; i < count; i++)
{
IntPtr item = IntPtr.Add(arr, i * structSize);
var native = Marshal.PtrToStructure<TtsEngineInfoNative>(item);
list.Add(new TtsEngineInfo(
PtrToUtf8(native.Id),
PtrToUtf8(native.Name),
native.NeedsCredentials,
PtrToUtf8(native.CredentialKeysJson)));
}
Native.tts_free_engines(arr, count);
return list;
}
private static string PtrToUtf8(IntPtr ptr) =>
ptr == IntPtr.Zero ? "" : (Marshal.PtrToStringAnsi(ptr) ?? "");
}