diff --git a/.github/workflows/bindings.yml b/.github/workflows/bindings.yml new file mode 100644 index 0000000..f98ff50 --- /dev/null +++ b/.github/workflows/bindings.yml @@ -0,0 +1,125 @@ +name: Bindings + +# Full test matrix for the language bindings over the C ABI: +# Rust conformance + C harness + Node + .NET + Swift, on the OSes each +# toolchain supports. Mirrors bindings/README.md ("Running the suites"). +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + rust-conformance: + name: Rust ABI conformance (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + features: system,cloud + - os: macos-latest + features: avsynth,cloud + - os: windows-latest + features: sapi,cloud + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install native deps (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libspeechd-dev libclang-dev + - name: ABI conformance suites + run: cargo test --no-default-features --features ${{ matrix.features }} --test ffi_conformance --test ffi_lifecycle --test ffi_safety + + c-harness: + name: C harness (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install native deps (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libspeechd-dev libclang-dev + - name: Build + compile header with -Wall -Wextra -Werror + run + run: make -C bindings/c test FEATURES=${{ runner.os == 'macOS' && 'avsynth,cloud' || 'system,cloud' }} + + node: + name: Node (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install native deps (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libspeechd-dev libclang-dev + - name: Build library + shell: bash + run: cargo build --no-default-features --features ${{ runner.os == 'macOS' && 'avsynth,cloud' || runner.os == 'Windows' && 'sapi,cloud' || 'system,cloud' }} + - name: npm install + test + working-directory: bindings/nodejs + run: | + npm install --no-fund --no-audit + npm test + + dotnet: + name: .NET (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "9.0.x" + - name: Install native deps (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libspeechd-dev libclang-dev + - name: Build library (release) + shell: bash + run: cargo build --release --no-default-features --features ${{ runner.os == 'Windows' && 'sapi,cloud' || 'system,cloud' }} + - name: Set TTS_WRAPPER_LIB + shell: bash + run: | + if [ "${{ runner.os }}" = Windows ]; then + LIB=$(cygpath -w "$PWD/target/release/rust_tts_wrapper.dll") + else + LIB="$PWD/target/release/librust_tts_wrapper.${{ runner.os == 'macOS' && 'dylib' || 'so' }}" + fi + echo "TTS_WRAPPER_LIB=$LIB" >> "$GITHUB_ENV" + - name: dotnet test + working-directory: bindings/dotnet + run: dotnet test tests/RustTtsWrapper.Bindings.Tests.csproj -p:SkipNativeLibCheck=true + + swift: + name: Swift (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Build library (debug) + run: cargo build --no-default-features --features avsynth,cloud + - name: Verify vendored header matches include/tts_wrapper.h + run: diff include/tts_wrapper.h bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h + - name: swift test + working-directory: bindings/swift + env: + TTS_WRAPPER_LIB_DIR: ${{ github.workspace }}/target/debug + run: swift test diff --git a/.gitignore b/.gitignore index b4c66e2..874cf8e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ Cargo.lock .env .env.* !.env.example + +# Language-binding build artifacts +node_modules/ +**/bin/ +**/obj/ diff --git a/README.md b/README.md index 44eda49..630e6c4 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,12 @@ cargo test --all-features ## Bindings +Every binding wraps the flat C ABI in `include/tts_wrapper.h`; see +**[bindings/README.md](bindings/README.md)** for the full guide (loading +conventions, test matrix, which package to use). All five suites — Rust +ABI conformance, a C harness compiled with `-Wall -Wextra -Werror`, Node, +.NET and Swift — run in CI on every push (`.github/workflows/bindings.yml`). + ### Python (`bindings/python/tts_wrapper.py`) ```python @@ -280,36 +286,55 @@ from tts_wrapper import TTSClient client = TTSClient("openai", {"apiKey": "your-key"}) client.on_audio(lambda chunk: print(f"{len(chunk)} bytes")) -client.on_boundary(lambda word, s, e: print(f"{word}: {s:.3f}-{e:.3f}")) +# word, char_offset, char_len, start_s, end_s, estimated +client.on_boundary(lambda w, off, ln, s, e, est: print(f"{w}: {s:.3f}-{e:.3f}{'~' if est else ''}")) client.set_voice("alloy") client.speak_sync("Hello world") client.stop() ``` -### .NET (`bindings/dotnet/TtsClient.cs`) +### .NET (`bindings/dotnet/` — NuGet: `RustTtsWrapper.Bindings`) ```csharp -using TtsWrapper; +using RustTtsWrapper; -var client = new TtsClient("openai", new() { {"apiKey", "your-key"} }); +using var client = new TtsClient("openai", new() { ["apiKey"] = "your-key" }); +client.SetOnBoundary((word, offset, len, start, end, estimated) => + Console.WriteLine($"{word}: {start:F3}-{end:F3} {(estimated ? "estimated" : "measured")}")); client.SetVoice("alloy"); -client.SetRate(1.0f); -client.SetPitch(1.0f); -client.SetVolume(1.0f); client.SpeakSync("Hello world"); -client.Stop(); ``` -### Swift (`bindings/swift/TtsClient.swift`) +### Swift (`bindings/swift/` — SwiftPM package `RustTtsWrapper`) ```swift -let client = TTSClient(engineId: "openai", credentials: ["apiKey": "your-key"]) +let client = try TtsClient(engineId: "openai", credentials: ["apiKey": "your-key"]) +client.setOnBoundary { word, offset, len, start, end, estimated in + print("\(word): \(start)-\(end) \(estimated ? "estimated" : "measured")") +} client.setVoice("alloy") -client.setRate(1.0) -client.speakSync("Hello world") -client.stop() +try client.speakSync("Hello world") ``` +### Node (`bindings/nodejs/` — npm: `@aactools/tts-wrapper`) + +```js +const { TtsClient } = require("@aactools/tts-wrapper"); + +const client = new TtsClient({ engineId: "openai", credentials: { apiKey: "your-key" } }); +client.on("boundary", ({ word, startSec, endSec, estimated }) => + console.log(`${word}: ${startSec}-${endSec} ${estimated ? "estimated" : "measured"}`)); +client.setVoice("alloy"); +client.speakSync("Hello world"); +client.close(); +``` + +### C (`bindings/c/` — reference harness) + +`bindings/c/tts_abi_harness.c` exercises the whole ABI against the +cdylib; `make -C bindings/c test` builds, compiles the header with +`-Wall -Wextra -Werror` and runs it. + ## Architecture ``` diff --git a/bindings/README.md b/bindings/README.md new file mode 100644 index 0000000..017773b --- /dev/null +++ b/bindings/README.md @@ -0,0 +1,110 @@ +# Language bindings for the rust-tts-wrapper C ABI + +Every binding wraps the **flat C ABI** declared in +[`include/tts_wrapper.h`](../include/tts_wrapper.h) (generated by cbindgen — +`cargo build` refreshes it). One library, one ABI, five ways to reach it. + +> **ABI stability:** none promised yet. The surface tracks the Rust crate +> minor version; breaking changes are announced in release notes (the +> boundary callback was consolidated in v0.4.1, for example). All consumers +> are in-repo or in projects we maintain. + +## The surface at a glance + +| Area | Symbols | +|---|---| +| Lifecycle | `tts_create`, `tts_destroy` | +| Synthesis | `tts_speak`, `tts_speak_ssml`, `tts_speak_sync`, `tts_synth_to_bytes`, `tts_free_bytes` | +| Control | `tts_stop`, `tts_pause`, `tts_resume` | +| Settings | `tts_set_voice`, `tts_set_rate`, `tts_set_pitch`, `tts_set_volume` | +| Events | `tts_set_on_audio`, `tts_set_on_boundary` (7-arg incl. `estimated`), `tts_set_on_mark`, `tts_set_on_viseme`, `tts_set_on_start`, `tts_set_on_end`, `tts_set_on_error` | +| Enumeration | `tts_get_voices`/`tts_free_voices`, `tts_get_engine_count`, `tts_get_engines`/`tts_free_engines` | +| Errors | `tts_get_last_error` (null ctx → global error; `NULL` = no error) | + +Conventions that hold across the ABI: + +- **Null-hardening:** every setter accepts a null ctx as a no-op; every + out-pointer API returns non-zero instead of crashing on null args. +- **Ownership:** arrays and byte buffers returned by the ABI are freed by + the matching `tts_free_*`; error strings are borrowed from the ctx. +- **Callbacks:** all callbacks are cdecl C function pointers taking a + `void *userdata`; out-parameters are annotated in the header docs. + +## Building the library + +```sh +cargo build --release --no-default-features --features system,cloud # Linux +cargo build --release --no-default-features --features avsynth,cloud # macOS +cargo build --release --no-default-features --features sapi,cloud # Windows +``` + +Artifacts: `librust_tts_wrapper.so` / `librust_tts_wrapper.dylib` / +`rust_tts_wrapper.dll` (+ `.a` staticlib). Prebuilt binaries ship with each +[GitHub release](https://github.com/AACTools/rust-tts-wrapper/releases). + +## The bindings + +| Directory | Package | Loads via | Test suite | +|---|---|---|---| +| [`c/`](c/) | none (reference) | link-time, `-lrust_tts_wrapper` | `make test` (C harness, `-Wall -Wextra -Werror`) | +| [`python/`](python/) | `tts_wrapper.py` | ctypes `CDLL` | inline ctypes smoke | +| [`dotnet/`](dotnet/) | `RustTtsWrapper.Bindings` (NuGet) | P/Invoke (+ `TTS_WRAPPER_LIB` resolver, `runtimes/{rid}/native`) | `dotnet test` (xunit) | +| [`swift/`](swift/) | SwiftPM `RustTtsWrapper` | link-time (`TTS_WRAPPER_LIB_DIR`), module `CRustTtsWrapper` | `swift test` (XCTest) | +| [`nodejs/`](nodejs/) | `@aactools/tts-wrapper` (npm) | koffi dlopen (`TTS_WRAPPER_LIB` or search path) | `npm test` (node:test) | + +All five suites assert the **same contract** (engine enumeration, lifecycle, +setters, callback registration, deterministic failure with a dummy cloud +key), so a regression in the ABI trips every suite, and the C harness +additionally verifies the header compiles as clean C11. + +## Running the suites + +```sh +# 1. Rust conformance (the ABI itself) +cargo test --test ffi_conformance --test ffi_lifecycle --test ffi_safety + +# 2. C harness +(cd bindings/c && make test) + +# 3. Node +(cd bindings/nodejs && npm install && npm test) + +# 4. .NET (needs the dotnet SDK) +(cd bindings/dotnet && dotnet test) +# with an explicit library: TTS_WRAPPER_LIB=/path/to/lib.so dotnet test + +# 5. Swift (macOS; needs Xcode toolchain) +(cd bindings/swift && TTS_WRAPPER_LIB_DIR=$PWD/../../target/debug swift test) +``` + +CI (`.github/workflows/bindings.yml`) runs the full matrix on every push to +main and on PRs. + +## Which binding should I use? + +- **Rust app/plugin:** depend on the crate directly; the C ABI exists for + non-Rust hosts. +- **C/C++ app:** link the cdylib, include `tts_wrapper.h`. See + `bindings/c/tts_abi_harness.c` for a complete worked example. +- **Python:** `bindings/python/tts_wrapper.py` (ctypes, zero dependencies). +- **.NET / AAC apps on Windows:** the NuGet package bundles the x64 + x86 + DLLs; `TtsClient` is the low-level client, `RustTtsClient` is a + drop-in `DotNetTtsWrapper.AbstractTtsClient` adapter. +- **Apple platforms:** SwiftPM package; in an Xcode app build the staticlib + and import the header as module `rust_tts_wrapper`. +- **Node/Electron:** `@aactools/tts-wrapper` (koffi-based; no node-gyp). + +## Engine feature sets + +Engines are compile-time cargo features; the `tts_get_engines` enumeration +reflects what was compiled in. The CI matrices build: + +- Linux: `system,cloud` (speech-dispatcher + cloud engines) +- macOS: `avsynth,cloud` +- Windows: `sapi,cloud` +- Optional everywhere: `sherpaonnx`, `floravox` (local models — see the + [floravox](https://github.com/AACTools/floravox) repo) + +Credentials are passed as a JSON object string at `tts_create` time; each +engine's required keys are listed in `tts_get_engines` +(`credential_keys_json`). diff --git a/bindings/c/Makefile b/bindings/c/Makefile new file mode 100644 index 0000000..4a4ee5f --- /dev/null +++ b/bindings/c/Makefile @@ -0,0 +1,43 @@ +# C ABI acceptance harness. +# +# Builds the cdylib (debug), compiles the harness against the cbindgen +# header with -Wall -Wextra -Werror, links, and runs it. +# +# Usage: +# make test # build lib + compile + run (default) +# make FEATURES=... # override cargo features (default: system,cloud) +# make clean + +CARGO ?= cargo +CC ?= cc +FEATURES ?= system,cloud + +# macOS adds -Wall-ish defaults; keep flags identical everywhere. +CFLAGS = -std=c11 -Wall -Wextra -Werror -I../../include + +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) + LIB := ../../target/debug/librust_tts_wrapper.dylib + LOAD := DYLD_LIBRARY_PATH=../../target/debug +else + LIB := ../../target/debug/librust_tts_wrapper.so + LOAD := LD_LIBRARY_PATH=../../target/debug +endif + +HARNESS := tts_abi_harness + +.PHONY: test lib run clean + +test: run + +lib: + $(CARGO) build -p rust-tts-wrapper --no-default-features --features $(FEATURES) + +$(HARNESS): tts_abi_harness.c lib + $(CC) $(CFLAGS) tts_abi_harness.c -o $(HARNESS) -L../../target/debug -lrust_tts_wrapper + +run: $(HARNESS) + $(LOAD) ./$(HARNESS) + +clean: + rm -f $(HARNESS) diff --git a/bindings/c/tts_abi_harness b/bindings/c/tts_abi_harness new file mode 100755 index 0000000..aa901f2 Binary files /dev/null and b/bindings/c/tts_abi_harness differ diff --git a/bindings/c/tts_abi_harness.c b/bindings/c/tts_abi_harness.c new file mode 100644 index 0000000..cf4b74b --- /dev/null +++ b/bindings/c/tts_abi_harness.c @@ -0,0 +1,238 @@ +/* + * C ABI acceptance harness for rust-tts-wrapper. + * + * Compiles against the cbindgen header with -Wall -Wextra -Werror (the + * header must be clean C) and links the cdylib, then exercises the ABI + * the way the language bindings do: + * + * engine enumeration → create (cloud engine, dummy creds, offline) → + * setters → all callback registrations → speak/speak_ssml/speak_sync + * (must fail cleanly with a dummy key, never crash) → synth_to_bytes + * error path → last_error → free_* → destroy. + * + * Exit code 0 = the C ABI contract holds. Any assertion failure exits 1 + * with a message on stderr. + * + * Build & run: see Makefile (or bindings/README.md). + */ + +#include "tts_wrapper.h" + +#include +#include +#include +#include + +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL: %s (line %d)\n", msg, __LINE__); \ + exit(1); \ + } \ + } while (0) + +/* Callback sinks: signatures must match the header typedefs exactly. */ +static int audio_calls = 0; +static void on_audio(const uint8_t *data, uintptr_t len, void *userdata) { + (void)data; + (void)len; + (void)userdata; + audio_calls++; +} + +static int boundary_calls = 0; +static void on_boundary(const char *word, int32_t char_offset, int32_t char_len, + float start_s, float end_s, int32_t estimated, + void *userdata) { + (void)word; + (void)char_offset; + (void)char_len; + (void)start_s; + (void)end_s; + (void)estimated; + (void)userdata; + boundary_calls++; +} + +static int mark_calls = 0; +static void on_mark(const char *name, int32_t char_offset, float start_s, + float end_s, void *userdata) { + (void)name; + (void)char_offset; + (void)start_s; + (void)end_s; + (void)userdata; + mark_calls++; +} + +static int viseme_calls = 0; +static void on_viseme(int32_t viseme_id, float offset_s, void *userdata) { + (void)viseme_id; + (void)offset_s; + (void)userdata; + viseme_calls++; +} + +static int start_calls = 0; +static void on_start(void *userdata) { + (void)userdata; + start_calls++; +} + +static int end_calls = 0; +static void on_end(void *userdata) { + (void)userdata; + end_calls++; +} + +static int error_calls = 0; +static void on_error(const char *message, void *userdata) { + (void)message; + (void)userdata; + error_calls++; +} + +static void check_engines(void) { + int32_t count = tts_get_engine_count(); + CHECK(count > 0, "engine count must be positive"); + + tts_engine_info *engines = NULL; + int32_t listed = 0; + int32_t rc = tts_get_engines(&engines, &listed); + CHECK(rc == 0, "tts_get_engines must succeed"); + CHECK(engines != NULL, "tts_get_engines must return an array"); + CHECK(listed == count, "listed engines must match engine count"); + for (int32_t i = 0; i < listed; i++) { + CHECK(engines[i].id != NULL, "engine id must be non-null"); + CHECK(engines[i].name != NULL, "engine name must be non-null"); + } + tts_free_engines(engines, listed); + + /* Hardening: null out-pointers return an error, not a crash. */ + CHECK(tts_get_engines(NULL, NULL) != 0, "null out-args must return error"); + tts_free_engines(NULL, 0); +} + +static tts_ctx *check_create(void) { + tts_ctx *ctx = tts_create("openai", "{\"apiKey\":\"dummy-key-for-c-harness\"}"); + CHECK(ctx != NULL, "tts_create(openai) must succeed offline"); + return ctx; +} + +static void check_setters(tts_ctx *ctx) { + tts_set_voice(ctx, "alloy"); + tts_set_voice(ctx, ""); /* empty is accepted */ + tts_set_voice(ctx, NULL); /* null is a no-op */ + tts_set_rate(ctx, 1.5f); + tts_set_pitch(ctx, 0.8f); + tts_set_volume(ctx, 0.9f); + + /* Null ctx is accepted as a no-op for every setter. */ + tts_set_voice(NULL, "alloy"); + tts_set_rate(NULL, 1.0f); + tts_set_pitch(NULL, 1.0f); + tts_set_volume(NULL, 1.0f); +} + +static void check_callbacks(tts_ctx *ctx) { + tts_set_on_audio(ctx, on_audio, NULL); + tts_set_on_boundary(ctx, on_boundary, NULL); + tts_set_on_mark(ctx, on_mark, NULL); + tts_set_on_viseme(ctx, on_viseme, NULL); + tts_set_on_start(ctx, on_start, NULL); + tts_set_on_end(ctx, on_end, NULL); + tts_set_on_error(ctx, on_error, NULL); + + /* Clear + re-register must be silent no-ops. */ + tts_set_on_boundary(ctx, NULL, NULL); + tts_set_on_boundary(ctx, on_boundary, NULL); + + /* Null ctx accepted. */ + tts_set_on_audio(NULL, on_audio, NULL); + tts_set_on_boundary(NULL, on_boundary, NULL); + tts_set_on_mark(NULL, on_mark, NULL); + tts_set_on_viseme(NULL, on_viseme, NULL); + tts_set_on_start(NULL, on_start, NULL); + tts_set_on_end(NULL, on_end, NULL); + tts_set_on_error(NULL, on_error, NULL); +} + +static void check_synth_failure_surface(tts_ctx *ctx) { + /* All three speak entry points must fail cleanly (dummy key), not + * crash; the offline contract mirrors the Rust conformance suite. */ + CHECK(tts_speak(ctx, "hello c abi") != 0, "tts_speak must fail offline"); + CHECK(tts_speak_ssml(ctx, "hello ") != 0, + "tts_speak_ssml must fail offline"); + CHECK(tts_speak_sync(ctx, "hello c abi") != 0, "tts_speak_sync must fail offline"); + + /* null text → error, not crash */ + CHECK(tts_speak(ctx, NULL) != 0, "null text must return error"); + CHECK(tts_speak_ssml(ctx, NULL) != 0, "null ssml must return error"); + CHECK(tts_speak_sync(ctx, NULL) != 0, "null text (sync) must return error"); + CHECK(tts_speak(NULL, "x") != 0, "null ctx must return error"); + + uint8_t *bytes = NULL; + uintptr_t len = 0; + CHECK(tts_synth_to_bytes(ctx, "hello c abi", &bytes, &len) != 0, + "synth_to_bytes must fail offline"); + CHECK(bytes == NULL, "no buffer handed out on failure"); + CHECK(len == 0, "length is zero on failure"); + CHECK(tts_synth_to_bytes(ctx, NULL, &bytes, &len) != 0, + "null text (synth) must return error"); + + /* The failed synth must populate last_error with a real message. */ + const char *err = tts_get_last_error(ctx); + CHECK(err != NULL, "last_error must be populated after failure"); + CHECK(strlen(err) > 0, "last_error must be non-empty"); + + tts_free_bytes(NULL, 0); +} + +static void check_voices(tts_ctx *ctx) { + tts_voice *voices = NULL; + int32_t count = -1; + int32_t rc = tts_get_voices(ctx, &voices, &count); + CHECK(rc == 0, "get_voices must succeed (empty is fine)"); + /* openai offline: network-free construction yields zero voices. */ + CHECK(count >= 0, "voice count must not be negative"); + if (count > 0) { + CHECK(voices != NULL, "voice array must be non-null"); + for (int32_t i = 0; i < count; i++) { + CHECK(voices[i].id != NULL, "voice id must be non-null"); + } + } + tts_free_voices(voices, count); + tts_free_voices(NULL, 0); + CHECK(tts_get_voices(ctx, NULL, NULL) != 0, + "null out-args must return error (voices)"); +} + +static void check_playback_control(tts_ctx *ctx) { + /* Safe on an idle context: accepted no-ops. */ + tts_stop(ctx); + tts_pause(ctx); + tts_resume(ctx); + tts_stop(NULL); + tts_pause(NULL); + tts_resume(NULL); +} + +int main(void) { + check_engines(); + + tts_ctx *ctx = check_create(); + check_setters(ctx); + check_callbacks(ctx); + check_playback_control(ctx); + check_voices(ctx); + check_synth_failure_surface(ctx); + + tts_destroy(ctx); + tts_destroy(NULL); + + printf("C ABI harness: OK (%d audio, %d boundary, %d mark, %d viseme, " + "%d start, %d end, %d error callbacks observed)\n", + audio_calls, boundary_calls, mark_calls, viseme_calls, start_calls, + end_calls, error_calls); + return 0; +} diff --git a/bindings/dotnet/RustTtsClient.cs b/bindings/dotnet/RustTtsClient.cs index 2fc52be..ab1961f 100644 --- a/bindings/dotnet/RustTtsClient.cs +++ b/bindings/dotnet/RustTtsClient.cs @@ -148,7 +148,7 @@ public override async Task SpeakStreamedAsync(string text, Action + _inner.SetOnBoundary((word, _offset, _len, start, end, _estimated) => wordCallback(new WordTimingEventArgs(word, start, end))); } diff --git a/bindings/dotnet/RustTtsWrapper.Bindings.csproj b/bindings/dotnet/RustTtsWrapper.Bindings.csproj index 4799019..54dd179 100644 --- a/bindings/dotnet/RustTtsWrapper.Bindings.csproj +++ b/bindings/dotnet/RustTtsWrapper.Bindings.csproj @@ -44,6 +44,14 @@ + + + + + +