From 93c4746eb255c7d09be17f1e9ac656e8c9effb6b Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 21 Aug 2026 08:19:09 +0000 Subject: [PATCH 1/7] =?UTF-8?q?bindings:=20full=20C-ABI=20test=20matrix=20?= =?UTF-8?q?=E2=80=94=20C=20harness,=20Node=20package,=20.NET/Swift/Python?= =?UTF-8?q?=20updates,=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every binding wraps the flat C ABI (include/tts_wrapper.h); this gives each one a real test suite and adds two new surfaces: - tests/ffi_conformance.rs: Rust-side ABI conformance (multi-context lifetimes, per-context error isolation, deterministic failure surface offline, callback replacement, mark registration hardening, the consolidated 7-arg boundary signature) - bindings/c: C acceptance harness — compiles the cbindgen header with -Wall -Wextra -Werror, links the cdylib, exercises the whole ABI (make test) - bindings/nodejs: NEW @aactools/tts-wrapper npm package (koffi dlopen, EventEmitter API, full typings) + node:test suite (9 tests) - bindings/dotnet: boundary callback updated to the consolidated 7-arg signature (+ mark/viseme P/Invokes), TTS_WRAPPER_LIB DllImport resolver, NEW xunit test project - bindings/swift: boundary callback updated (+ mark/viseme setters), NEW SwiftPM package (CRustTtsWrapper C target via symlinked header, linkedLibrary + TTS_WRAPPER_LIB_DIR), NEW XCTest suite - bindings/python: boundary CFUNCTYPE updated to the 7-arg signature - bindings/README.md: the binding guide (surface table, loading conventions, suite matrix, which binding to use) - .github/workflows/bindings.yml: CI matrix — Rust conformance (3 OSes), C harness (2), Node (3), .NET (2), Swift (macOS) --- .github/workflows/bindings.yml | 121 ++++++ .gitignore | 5 + README.md | 51 ++- bindings/README.md | 110 +++++ bindings/c/Makefile | 43 ++ bindings/c/tts_abi_harness | Bin 0 -> 21984 bytes bindings/c/tts_abi_harness.c | 238 +++++++++++ bindings/dotnet/RustTtsClient.cs | 2 +- bindings/dotnet/TtsClient.cs | 93 ++++- bindings/dotnet/tests/AbiConformanceTests.cs | 115 +++++ .../RustTtsWrapper.Bindings.Tests.csproj | 22 + bindings/nodejs/README.md | 46 ++ bindings/nodejs/package-lock.json | 28 ++ bindings/nodejs/package.json | 26 ++ bindings/nodejs/src/index.d.ts | 84 ++++ bindings/nodejs/src/index.js | 395 ++++++++++++++++++ bindings/nodejs/test/abi.test.js | 127 ++++++ bindings/python/tts_wrapper.py | 28 +- bindings/swift/Package.swift | 37 ++ .../CRustTtsWrapper/include/module.modulemap | 4 + .../CRustTtsWrapper/include/tts_wrapper.h | 1 + .../RustTtsWrapperAbiTests.swift | 101 +++++ bindings/swift/TtsClient.swift | 139 ++++-- tests/ffi_conformance.rs | 205 +++++++++ 24 files changed, 1962 insertions(+), 59 deletions(-) create mode 100644 .github/workflows/bindings.yml create mode 100644 bindings/README.md create mode 100644 bindings/c/Makefile create mode 100755 bindings/c/tts_abi_harness create mode 100644 bindings/c/tts_abi_harness.c create mode 100644 bindings/dotnet/tests/AbiConformanceTests.cs create mode 100644 bindings/dotnet/tests/RustTtsWrapper.Bindings.Tests.csproj create mode 100644 bindings/nodejs/README.md create mode 100644 bindings/nodejs/package-lock.json create mode 100644 bindings/nodejs/package.json create mode 100644 bindings/nodejs/src/index.d.ts create mode 100644 bindings/nodejs/src/index.js create mode 100644 bindings/nodejs/test/abi.test.js create mode 100644 bindings/swift/Package.swift create mode 100644 bindings/swift/Sources/CRustTtsWrapper/include/module.modulemap create mode 120000 bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h create mode 100644 bindings/swift/Tests/RustTtsWrapperTests/RustTtsWrapperAbiTests.swift create mode 100644 tests/ffi_conformance.rs diff --git a/.github/workflows/bindings.yml b/.github/workflows/bindings.yml new file mode 100644 index 0000000..823c38f --- /dev/null +++ b/.github/workflows/bindings.yml @@ -0,0 +1,121 @@ +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: | + LIB="$PWD/target/release/$([ "${{ runner.os }}" = Windows ] && echo rust_tts_wrapper.dll || echo librust_tts_wrapper.${{ runner.os == 'macOS' && 'dylib' || 'so' }})" + echo "TTS_WRAPPER_LIB=$LIB" >> "$GITHUB_ENV" + - name: dotnet test + working-directory: bindings/dotnet + run: dotnet test -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 header symlink resolves + run: test -f 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 0000000000000000000000000000000000000000..aa901f22cb722807bc885e89d8e6361f0dfaffb1 GIT binary patch literal 21984 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 UWnEg{t!7`=1OHr~fsYmc1$&-Ood5s; literal 0 HcmV?d00001 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/TtsClient.cs b/bindings/dotnet/TtsClient.cs index 3dda7f8..8e16a3e 100644 --- a/bindings/dotnet/TtsClient.cs +++ b/bindings/dotnet/TtsClient.cs @@ -13,6 +13,24 @@ public static class Native { private const string Lib = "rust_tts_wrapper"; + static Native() + { + // Optional explicit library location (absolute or relative path): + // TTS_WRAPPER_LIB=/path/to/librust_tts_wrapper.so + // When set, preload it and resolve the DllImport name to that + // handle — otherwise the OS loader's default search applies + // (runtimes/{rid}/native works automatically for NuGet consumers). + 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); @@ -34,10 +52,21 @@ public static class Native [UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate void AudioCallbackNative(IntPtr bytes, UIntPtr len, IntPtr userdata); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - public delegate void BoundaryCallbackNative(IntPtr word, float start, float end, IntPtr userdata); + 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); + // Mark/bookmark callback: cb(name, char_offset, start_s, end_s, 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); + + // Viseme callback for lip-sync: cb(viseme_id, audio_offset_sec, 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); + // Lifecycle callbacks [UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate void VoidCallbackNative(IntPtr userdata); @@ -142,7 +171,20 @@ public List CredentialKeys public delegate void AudioCallback(byte[] chunk); /// Word-boundary event handed to . -public delegate void BoundaryCallback(string word, float startTime, float endTime); +/// +/// / are -1 when the +/// engine does not report source positions. is +/// true when the timings are proportional estimates (unpatched voice, wpm +/// model), false when measured (floravox duration tensor, cloud timings). +/// +public delegate void BoundaryCallback(string word, int charOffset, int charLen, + float startTime, float endTime, bool estimated); + +/// Mark/bookmark event handed to . +public delegate void MarkCallback(string name, int charOffset, float startTime, float endTime); + +/// Viseme event for lip-sync handed to . +public delegate void VisemeCallback(int visemeId, float offsetSec); /// Lifecycle callback (no payload). public delegate void LifecycleCallback(); @@ -173,6 +215,8 @@ public class TtsClient : IDisposable // them while native code still holds a function pointer. 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; @@ -283,14 +327,55 @@ public void SetOnBoundary(BoundaryCallback? callback) return; } - _boundaryNative = (IntPtr wordPtr, float start, float end, IntPtr _userdata) => + _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, start, end); + callback(word, charOffset, charLen, start, end, estimated != 0); }; Native.tts_set_on_boundary(_ctx, _boundaryNative, IntPtr.Zero); } + /// + /// Register a mark/bookmark callback (SSML <mark>). Pass null to clear. + /// + 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); + } + + /// + /// Register a viseme callback for lip-sync / facial animation. + /// Pass null to clear. + /// + 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); + } + /// /// Register a callback fired when speech starts. Pass null to clear. /// diff --git a/bindings/dotnet/tests/AbiConformanceTests.cs b/bindings/dotnet/tests/AbiConformanceTests.cs new file mode 100644 index 0000000..a607efb --- /dev/null +++ b/bindings/dotnet/tests/AbiConformanceTests.cs @@ -0,0 +1,115 @@ +// ABI conformance tests for the .NET binding — mirrors bindings/c +// (the C acceptance harness) and tests/ffi_conformance.rs. +// +// The shared library must be built first: +// cargo build --no-default-features --features system,cloud (Linux) +// and located via TTS_WRAPPER_LIB, or on the OS loader's path. + +namespace RustTtsWrapper.Bindings.Tests; + +public class AbiConformanceTests +{ + private static TtsClient MakeClient() => + new("openai", new Dictionary + { + ["apiKey"] = "dummy-key-for-dotnet-tests", + }); + + [Fact] + public void EngineEnumerationMatchesCount() + { + int count = TtsClient.EngineCount(); + Assert.True(count > 0); + + var engines = TtsClient.ListEngines(); + Assert.Equal(count, engines.Count); + Assert.All(engines, e => + { + Assert.False(string.IsNullOrEmpty(e.Id)); + Assert.False(string.IsNullOrEmpty(e.Name)); + }); + Assert.Contains(engines, e => e.Id == "openai"); + } + + [Fact] + public void CreateDisposeRoundTrip_DoubleDisposeSafe() + { + var c = MakeClient(); + c.Dispose(); + c.Dispose(); + Assert.Throws(() => c.Speak("x")); + } + + [Fact] + public void CreateFailureSurfacesGlobalError() + { + var ex = Assert.Throws(() => new TtsClient("no-such-engine")); + Assert.Contains("no-such-engine", ex.Message); + } + + [Fact] + public void ManyClientsLiveSimultaneously() + { + var clients = Enumerable.Range(0, 8).Select(_ => MakeClient()).ToList(); + Assert.All(clients, c => Assert.NotNull(c.GetVoices())); + clients.ForEach(c => c.Dispose()); + } + + [Fact] + public void SettersAcceptTypicalValues() + { + using var c = MakeClient(); + c.SetVoice("alloy"); + c.SetVoice(""); + c.SetRate(1.5f); + c.SetPitch(0.8f); + c.SetVolume(0.9f); + c.Stop(); + c.Pause(); + c.Resume(); + } + + [Fact] + public void GetVoicesReturnsArray_EmptyOfflineIsFine() + { + using var c = MakeClient(); + var voices = c.GetVoices(); + Assert.All(voices, v => Assert.False(string.IsNullOrEmpty(v.Id))); + } + + [Fact] + public void DummyKeySynthesisFailsObservably() + { + // Offline → validation error; online → 401. Both must surface as + // a TtsException, never a silent success. + using var c = MakeClient(); + Assert.Throws(() => c.SpeakSync("hello dotnet")); + Assert.False(string.IsNullOrEmpty(c.GetLastError())); + Assert.ThrowsAny(() => c.SynthToBytes("hello dotnet")); + } + + [Fact] + public void CallbackRegistrationDoesNotThrow() + { + using var c = MakeClient(); + c.SetOnAudio(_ => { }); + c.SetOnBoundary((word, charOffset, charLen, start, end, estimated) => + { + _ = (word, charOffset, charLen, start, end, estimated); + }); + c.SetOnMark((name, charOffset, start, end) => _ = (name, charOffset, start, end)); + c.SetOnViseme((id, offsetSec) => _ = (id, offsetSec)); + c.SetOnStart(() => { }); + c.SetOnEnd(() => { }); + c.SetOnError(_ => { }); + + // Clearing is a silent no-op. + c.SetOnAudio(null); + c.SetOnBoundary(null); + c.SetOnMark(null); + c.SetOnViseme(null); + c.SetOnStart(null); + c.SetOnEnd(null); + c.SetOnError(null); + } +} diff --git a/bindings/dotnet/tests/RustTtsWrapper.Bindings.Tests.csproj b/bindings/dotnet/tests/RustTtsWrapper.Bindings.Tests.csproj new file mode 100644 index 0000000..ab5972f --- /dev/null +++ b/bindings/dotnet/tests/RustTtsWrapper.Bindings.Tests.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + RustTtsWrapper.Bindings.Tests + enable + enable + latest + false + + + + + + + + + + + + + diff --git a/bindings/nodejs/README.md b/bindings/nodejs/README.md new file mode 100644 index 0000000..cb5cf96 --- /dev/null +++ b/bindings/nodejs/README.md @@ -0,0 +1,46 @@ +# @aactools/tts-wrapper + +Node.js bindings for the rust-tts-wrapper C ABI. Loads the shared library +at runtime via [koffi](https://koffi.dev) — no node-gyp, no native +compilation of this package. + +## Install & setup + +Build (or download) the Rust library once: + +```sh +cargo build --release +``` + +produces `target/release/librust_tts_wrapper.so` (Linux), +`librust_tts_wrapper.dylib` (macOS) or `rust_tts_wrapper.dll` (Windows). + +The client finds it via `TTS_WRAPPER_LIB=/path/to/lib`, a +`runtimes/-/` directory next to the package, or the +repo's `target/{release,debug}`. See `resolveLibraryPath()` in +`src/index.js`. + +## Usage + +```js +const { TtsClient } = require("@aactools/tts-wrapper"); + +const client = new TtsClient({ engineId: "openai", credentials: { apiKey: "..." } }); +client.on("audio", (chunk) => stream.write(chunk)); +client.on("boundary", ({ word, startSec, endSec, estimated }) => { + console.log(`${word}: ${startSec}-${endSec} ${estimated ? "estimated" : "measured"}`); +}); +client.setVoice("alloy"); +client.speak("Hello world"); +client.close(); +``` + +Events: `audio` (Buffer), `boundary`, `mark`, `viseme`, `start`, `end`, +`error` (string). See `src/index.d.ts` for the full API. + +## Tests + +```sh +npm install +npm test +``` diff --git a/bindings/nodejs/package-lock.json b/bindings/nodejs/package-lock.json new file mode 100644 index 0000000..68bde72 --- /dev/null +++ b/bindings/nodejs/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "@aactools/tts-wrapper", + "version": "0.4.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@aactools/tts-wrapper", + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "koffi": "^2.10.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/koffi": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.3.tgz", + "integrity": "sha512-E9y1AsgYGlaxMhcZzHr8y96QF2U5XzA12GGVAfbWqIubTwPNMXQarfBzePNXHe0xtIEtNd6ifAv3GAKYGUeBAQ==", + "hasInstallScript": true, + "funding": { + "url": "https://liberapay.com/Koromix" + } + } + } +} diff --git a/bindings/nodejs/package.json b/bindings/nodejs/package.json new file mode 100644 index 0000000..c058c70 --- /dev/null +++ b/bindings/nodejs/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aactools/tts-wrapper", + "version": "0.4.1", + "description": "Node.js bindings for the rust-tts-wrapper C ABI (dlopen-based; no native compilation)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/AACTools/rust-tts-wrapper.git", + "directory": "bindings/nodejs" + }, + "main": "src/index.js", + "types": "src/index.d.ts", + "files": [ + "src", + "README.md" + ], + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "koffi": "^2.10.1" + } +} diff --git a/bindings/nodejs/src/index.d.ts b/bindings/nodejs/src/index.d.ts new file mode 100644 index 0000000..31a0dec --- /dev/null +++ b/bindings/nodejs/src/index.d.ts @@ -0,0 +1,84 @@ +/** + * Node.js bindings for the rust-tts-wrapper C ABI. + * See bindings/README.md for the library-loading conventions. + */ + +export interface TtsVoice { + id: string; + name: string; + language: string; + gender: string; + engine: string; +} + +export interface TtsEngineInfo { + id: string; + name: string; + needsCredentials: boolean; + credentialKeys: string[]; +} + +export interface WordBoundaryEvent { + word: string; + charOffset: number; + charLen: number; + startSec: number; + endSec: number; + /** true = proportional estimate, false = measured timings */ + estimated: boolean; +} + +export interface MarkEvent { + name: string; + charOffset: number; + startSec: number; + endSec: number; +} + +export interface VisemeEvent { + id: number; + offsetSec: number; +} + +export interface TtsClientOptions { + engineId?: string; + credentials?: Record; +} + +declare class TtsClient extends NodeJS.EventEmitter { + constructor(options?: TtsClientOptions); + + speak(text: string): void; + speakSsml(ssml: string): void; + speakSync(text: string): void; + synthToBytes(text: string): Buffer; + + stop(): void; + pause(): void; + resume(): void; + + setVoice(voiceId: string): void; + setRate(rate: number): void; + setPitch(pitch: number): void; + setVolume(volume: number): void; + + getVoices(): TtsVoice[]; + lastError(): string | null; + close(): void; + + on(event: "audio", listener: (chunk: Buffer) => void): this; + on(event: "boundary", listener: (ev: WordBoundaryEvent) => void): this; + on(event: "mark", listener: (ev: MarkEvent) => void): this; + on(event: "viseme", listener: (ev: VisemeEvent) => void): this; + on(event: "start" | "end", listener: () => void): this; + on(event: "error", listener: (message: string) => void): this; + + static listEngines(): TtsEngineInfo[]; + static engineCount(): number; + static globalLastError(): string | null; +} + +/** Preload the shared library (throws with guidance if not found). */ +export function loadLibrary(explicitPath?: string): unknown; + +export declare const TtsClientConstructor: typeof TtsClient; diff --git a/bindings/nodejs/src/index.js b/bindings/nodejs/src/index.js new file mode 100644 index 0000000..c95da43 --- /dev/null +++ b/bindings/nodejs/src/index.js @@ -0,0 +1,395 @@ +// Node.js bindings for the rust-tts-wrapper C ABI. +// +// Architecture: dlopen the shared library at runtime (koffi) and wrap the +// flat C surface in an EventEmitter-based client. No native compilation +// is required for this package — the library is built once with cargo +// (see bindings/README.md) and located at load time. +// +// Library resolution order: +// 1. TTS_WRAPPER_LIB env var (absolute or relative path) +// 2. /runtimes// ( packaged layout ) +// 3. /target/release/ ( dev layout ) +// 4. /target/debug/ +// 5. plain name (falls back to the OS loader's search path) + +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { EventEmitter } = require("node:events"); +const koffi = require("koffi"); + +const PLATFORM_NAMES = { + linux: "librust_tts_wrapper.so", + darwin: "librust_tts_wrapper.dylib", + win32: "rust_tts_wrapper.dll", +}; + +/** Resolve the shared library path for this platform. */ +function resolveLibraryPath(explicit) { + const candidates = []; + if (explicit) { + // An explicit path is a contract: fail loudly, never fall through. + if (!fs.existsSync(explicit)) { + throw new Error(`rust_tts_wrapper library not found at ${explicit}`); + } + return explicit; + } + const base = PLATFORM_NAMES[process.platform]; + if (!base) { + throw new Error(`unsupported platform: ${process.platform}`); + } + const pkgRoot = path.join(__dirname, ".."); + const repoRoot = path.join(pkgRoot, "..", ".."); + candidates.push(path.join(pkgRoot, "runtimes", `${process.platform}-${process.arch}`, base)); + candidates.push(path.join(repoRoot, "target", "release", base)); + candidates.push(path.join(repoRoot, "target", "debug", base)); + candidates.push(base); + for (const c of candidates) { + if (!c.includes(path.sep)) return c; // bare name → OS search path + if (fs.existsSync(c)) return c; + } + throw new Error( + `rust_tts_wrapper library not found; set TTS_WRAPPER_LIB or build with cargo (tried: ${candidates.join(", ")})`, + ); +} + +// koffi's named types AND callback prototypes are process-global: +// declare them exactly once, lazily. +let koffiTypes = null; +function getKoffiTypes() { + if (koffiTypes) return koffiTypes; + koffiTypes = { + TtsVoice: koffi.struct("tts_voice", { + id: koffi.pointer("const char"), + name: koffi.pointer("const char"), + language: koffi.pointer("const char"), + gender: koffi.pointer("const char"), + engine: koffi.pointer("const char"), + }), + TtsEngineInfo: koffi.struct("tts_engine_info", { + id: koffi.pointer("const char"), + name: koffi.pointer("const char"), + needs_credentials: koffi.types.uint8, + credential_keys_json: koffi.pointer("const char"), + }), + cbProtos: { + audio: koffi.proto("void audio_cb(const uint8_t *data, uintptr_t len, void *userdata)"), + boundary: koffi.proto( + "void boundary_cb(const char *word, int32_t char_offset, int32_t char_len, float start_s, float end_s, int32_t estimated, void *userdata)", + ), + mark: koffi.proto( + "void mark_cb(const char *name, int32_t char_offset, float start_s, float end_s, void *userdata)", + ), + viseme: koffi.proto("void viseme_cb(int32_t viseme_id, float offset_s, void *userdata)"), + start: koffi.proto("void start_cb(void *userdata)"), + end: koffi.proto("void end_cb(void *userdata)"), + error: koffi.proto("void error_cb(const char *message, void *userdata)"), + }, + }; + return koffiTypes; +} + +/** Load the C ABI. Exposed for tests and advanced embedding. */ +function loadLibrary(explicitPath) { + const libPath = resolveLibraryPath(explicitPath ?? process.env.TTS_WRAPPER_LIB); + const lib = koffi.load(libPath); + const { TtsVoice, TtsEngineInfo, cbProtos } = getKoffiTypes(); + + const protos = { + tts_create: lib.func("void *tts_create(const char *engine_id, const char *credentials_json)"), + tts_destroy: lib.func("void tts_destroy(void *ctx)"), + tts_speak: lib.func("int32_t tts_speak(void *ctx, const char *text)"), + tts_speak_ssml: lib.func("int32_t tts_speak_ssml(void *ctx, const char *ssml)"), + tts_speak_sync: lib.func("int32_t tts_speak_sync(void *ctx, const char *text)"), + tts_stop: lib.func("void tts_stop(void *ctx)"), + tts_pause: lib.func("void tts_pause(void *ctx)"), + tts_resume: lib.func("void tts_resume(void *ctx)"), + tts_synth_to_bytes: lib.func( + "int32_t tts_synth_to_bytes(void *ctx, const char *text, _Out_ uint8_t **out_bytes, _Out_ uintptr_t *out_len)", + ), + tts_free_bytes: lib.func("void tts_free_bytes(uint8_t *bytes, uintptr_t len)"), + tts_set_voice: lib.func("void tts_set_voice(void *ctx, const char *voice_id)"), + tts_set_rate: lib.func("void tts_set_rate(void *ctx, float rate)"), + tts_set_pitch: lib.func("void tts_set_pitch(void *ctx, float pitch)"), + tts_set_volume: lib.func("void tts_set_volume(void *ctx, float volume)"), + tts_get_voices: lib.func( + "int32_t tts_get_voices(void *ctx, _Out_ tts_voice **out_voices, _Out_ int32_t *out_count)", + ), + tts_free_voices: lib.func("void tts_free_voices(tts_voice *voices, int32_t count)"), + tts_get_engine_count: lib.func("int32_t tts_get_engine_count()"), + tts_get_engines: lib.func( + "int32_t tts_get_engines(_Out_ tts_engine_info **out_engines, _Out_ int32_t *out_count)", + ), + tts_free_engines: lib.func("void tts_free_engines(tts_engine_info *engines, int32_t count)"), + tts_get_last_error: lib.func("const char *tts_get_last_error(void *ctx)"), + }; + + const setters = { + audio: lib.func("void tts_set_on_audio(void *ctx, audio_cb *cb, void *userdata)"), + boundary: lib.func("void tts_set_on_boundary(void *ctx, boundary_cb *cb, void *userdata)"), + mark: lib.func("void tts_set_on_mark(void *ctx, mark_cb *cb, void *userdata)"), + viseme: lib.func("void tts_set_on_viseme(void *ctx, viseme_cb *cb, void *userdata)"), + start: lib.func("void tts_set_on_start(void *ctx, start_cb *cb, void *userdata)"), + end: lib.func("void tts_set_on_end(void *ctx, end_cb *cb, void *userdata)"), + error: lib.func("void tts_set_on_error(void *ctx, error_cb *cb, void *userdata)"), + }; + + return { lib, libPath, protos, cbProtos, setters, TtsVoice, TtsEngineInfo }; +} + +// koffi auto-converts `const char *` return values and struct fields to +// JS strings (null when the pointer is null). +function cstr(s) { + return s ?? ""; +} + +/** + * Event-emitting client over one engine instance (a `tts_ctx`). + * + * Events: "audio" (Buffer), "boundary" ({word, charOffset, charLen, + * startSec, endSec, estimated}), "mark" ({name, charOffset, startSec, + * endSec}), "viseme" ({id, offsetSec}), "start", "end", "error" (string). + */ +class TtsClient extends EventEmitter { + /** + * @param {object} [options] + * @param {string} [options.engineId="system"] + * @param {Record} [options.credentials] + * @param {object} [options.library] preloaded ABI (from loadLibrary) + */ + constructor({ engineId = "system", credentials = {}, library } = {}) { + super(); + this._abi = library ?? loadLibrary(); + this._ctx = this._abi.protos.tts_create( + engineId, + JSON.stringify(credentials ?? {}), + ); + if (!this._ctx) { + throw new Error( + `tts_create(${engineId}) failed: ${TtsClient.globalLastError(this._abi) ?? "unknown"}`, + ); + } + this._registered = []; // koffi callback handles kept alive + this._closed = false; + this._registerAllEvents(); + } + + // --- synthesis --------------------------------------------------------- + + speak(text) { + this._throwIfClosed(); + const rc = this._abi.protos.tts_speak(this._ctx, text); + if (rc !== 0) throw new Error(this._lastError() ?? "tts_speak failed"); + } + + speakSsml(ssml) { + this._throwIfClosed(); + const rc = this._abi.protos.tts_speak_ssml(this._ctx, ssml); + if (rc !== 0) throw new Error(this._lastError() ?? "tts_speak_ssml failed"); + } + + speakSync(text) { + this._throwIfClosed(); + const rc = this._abi.protos.tts_speak_sync(this._ctx, text); + if (rc !== 0) throw new Error(this._lastError() ?? "tts_speak_sync failed"); + } + + /** Synthesise to a Buffer. Returns an empty Buffer on zero audio. */ + synthToBytes(text) { + this._throwIfClosed(); + const out = [null]; + const outLen = [0n]; + const rc = this._abi.protos.tts_synth_to_bytes(this._ctx, text, out, outLen); + if (rc !== 0) throw new Error(this._lastError() ?? "tts_synth_to_bytes failed"); + const len = Number(outLen[0]); + const ptr = out[0]; + if (!ptr || len === 0) return Buffer.alloc(0); + try { + return Buffer.from(koffi.decode(ptr, "uint8_t", Number(len))); + } finally { + this._abi.protos.tts_free_bytes(ptr, BigInt(len)); + } + } + + // --- playback control -------------------------------------------------- + + stop() { + this._throwIfClosed(); + this._abi.protos.tts_stop(this._ctx); + } + pause() { + this._throwIfClosed(); + this._abi.protos.tts_pause(this._ctx); + } + resume() { + this._throwIfClosed(); + this._abi.protos.tts_resume(this._ctx); + } + + // --- settings ---------------------------------------------------------- + + setVoice(voiceId) { + this._throwIfClosed(); + this._abi.protos.tts_set_voice(this._ctx, voiceId ?? ""); + } + setRate(rate) { + this._throwIfClosed(); + this._abi.protos.tts_set_rate(this._ctx, rate); + } + setPitch(pitch) { + this._throwIfClosed(); + this._abi.protos.tts_set_pitch(this._ctx, pitch); + } + setVolume(volume) { + this._throwIfClosed(); + this._abi.protos.tts_set_volume(this._ctx, volume); + } + + // --- enumeration ------------------------------------------------------- + + /** @returns {{id:string,name:string,language:string,gender:string,engine:string}[]} */ + getVoices() { + this._throwIfClosed(); + const out = [null]; + const outCount = [0]; + const rc = this._abi.protos.tts_get_voices(this._ctx, out, outCount); + if (rc !== 0) throw new Error(this._lastError() ?? "tts_get_voices failed"); + const count = outCount[0]; + const arr = out[0]; + if (!arr || count <= 0) return []; + try { + const voices = koffi.decode(arr, this._abi.TtsVoice, count); + return voices.map((v) => ({ + id: cstr(v.id), + name: cstr(v.name), + language: cstr(v.language), + gender: cstr(v.gender), + engine: cstr(v.engine), + })); + } finally { + this._abi.protos.tts_free_voices(arr, count); + } + } + + /** @returns {{id:string,name:string,needsCredentials:boolean,credentialKeys:string[]}[]} */ + static listEngines(abi) { + const a = abi ?? loadLibrary(); + const out = [null]; + const outCount = [0]; + const rc = a.protos.tts_get_engines(out, outCount); + if (rc !== 0) { + throw new Error(TtsClient.globalLastError(a) ?? "tts_get_engines failed"); + } + const count = outCount[0]; + const arr = out[0]; + if (!arr || count <= 0) return []; + try { + const engines = koffi.decode(arr, a.TtsEngineInfo, count); + return engines.map((e) => ({ + id: cstr(e.id), + name: cstr(e.name), + needsCredentials: e.needs_credentials !== 0, + credentialKeys: JSON.parse(cstr(e.credential_keys_json) || "[]"), + })); + } finally { + a.protos.tts_free_engines(arr, count); + } + } + + static engineCount(abi) { + return (abi ?? loadLibrary()).protos.tts_get_engine_count(); + } + + // --- errors / lifecycle ------------------------------------------------ + + lastError() { + return this._lastError(); + } + + static globalLastError(abi) { + const p = (abi ?? loadLibrary()).protos.tts_get_last_error(null); + const s = cstr(p); + return s.length ? s : null; + } + + close() { + if (this._closed) return; + this._closed = true; + // Clear every callback so no dangling trampoline fires after destroy. + for (const key of Object.keys(this._abi.setters)) { + try { + this._abi.setters[key](this._ctx, null, null); + } catch { + /* best effort */ + } + } + for (const handle of this._registered) { + try { + koffi.unregister(handle); + } catch { + /* best effort */ + } + } + this._registered = []; + this._abi.protos.tts_destroy(this._ctx); + this._ctx = null; + } + + [Symbol.dispose]() { + this.close(); + } + + // --- internal ---------------------------------------------------------- + + _lastError() { + const p = this._abi.protos.tts_get_last_error(this._ctx); + const s = cstr(p); + return s.length ? s : null; + } + + _throwIfClosed() { + if (this._closed) throw new Error("TtsClient is closed"); + } + + _registerAllEvents() { + const { cbProtos, setters } = this._abi; + + const reg = (key, fn) => { + const handle = koffi.register(fn, koffi.pointer(cbProtos[key])); + this._registered.push(handle); + setters[key](this._ctx, handle, null); + }; + + reg("audio", (data, len) => { + const n = Number(len); + this.emit("audio", n > 0 && data ? Buffer.from(data.subarray(0, n)) : Buffer.alloc(0)); + }); + reg("boundary", (word, charOffset, charLen, startS, endS, estimated) => { + this.emit("boundary", { + word: cstr(word), + charOffset, + charLen, + startSec: startS, + endSec: endS, + estimated: estimated !== 0, + }); + }); + reg("mark", (name, charOffset, startS, endS) => { + this.emit("mark", { + name: cstr(name), + charOffset, + startSec: startS, + endSec: endS, + }); + }); + reg("viseme", (id, offsetS) => { + this.emit("viseme", { id, offsetSec: offsetS }); + }); + reg("start", () => this.emit("start")); + reg("end", () => this.emit("end")); + reg("error", (msg) => this.emit("error", cstr(msg))); + } +} + +module.exports = { TtsClient, loadLibrary, resolveLibraryPath }; diff --git a/bindings/nodejs/test/abi.test.js b/bindings/nodejs/test/abi.test.js new file mode 100644 index 0000000..2e26da5 --- /dev/null +++ b/bindings/nodejs/test/abi.test.js @@ -0,0 +1,127 @@ +// ABI conformance tests for the Node.js binding — mirrors bindings/c +// (the C acceptance harness) and tests/ffi_conformance.rs. +// +// Requires the shared library to be built: +// cargo build --no-default-features --features system,cloud +// (or set TTS_WRAPPER_LIB to an explicit path). + +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { TtsClient, loadLibrary } = require("../src/index.js"); + +function makeClient() { + // openai constructs offline; synthesis fails deterministically with a + // dummy key — the exact contract these tests assert. + return new TtsClient({ + engineId: "openai", + credentials: { apiKey: "dummy-key-for-node-tests" }, + }); +} + +test("engine enumeration", () => { + const count = TtsClient.engineCount(); + assert.ok(count > 0, "engine count must be positive"); + + const engines = TtsClient.listEngines(); + assert.equal(engines.length, count, "listEngines matches engineCount"); + for (const e of engines) { + assert.ok(typeof e.id === "string" && e.id.length > 0, "engine id"); + assert.ok(typeof e.name === "string" && e.name.length > 0, "engine name"); + assert.equal(typeof e.needsCredentials, "boolean", "needsCredentials"); + assert.ok(Array.isArray(e.credentialKeys), "credentialKeys array"); + } + assert.ok(engines.some((e) => e.id === "openai"), "openai is compiled in"); +}); + +test("create / lifecycle / double close", () => { + const c = makeClient(); + c.close(); + c.close(); // idempotent + assert.throws(() => c.speak("x"), /closed/); +}); + +test("create failure surfaces the global error", () => { + assert.throws(() => new TtsClient({ engineId: "no-such-engine" }), /tts_create/); +}); + +test("many clients live simultaneously", () => { + const clients = Array.from({ length: 8 }, makeClient); + for (const c of clients) assert.ok(c.getVoices() !== undefined); + for (const c of clients) c.close(); +}); + +test("setters accept typical values", () => { + const c = makeClient(); + c.setVoice("alloy"); + c.setVoice(""); + c.setRate(1.5); + c.setPitch(0.8); + c.setVolume(0.9); + c.stop(); + c.pause(); + c.resume(); + c.close(); +}); + +test("getVoices returns an array (empty offline is fine)", () => { + const c = makeClient(); + const voices = c.getVoices(); + assert.ok(Array.isArray(voices)); + for (const v of voices) { + assert.ok(typeof v.id === "string", "voice id is a string"); + } + c.close(); +}); + +test("speak failures surface as throws or error events (dummy key)", () => { + const c = makeClient(); + const errors = []; + c.on("error", (msg) => errors.push(msg)); + + // With a dummy key every path fails, offline (validation) or online + // (401) — either as a throw or via the error event. Never silently. + const outcomes = []; + outcomes.push(tryCall(() => c.speakSync("hello node"))); + outcomes.push(tryCall(() => c.synthToBytes("hello node"))); + c.close(); + + const failed = outcomes.some((o) => o === "threw") || errors.length > 0; + assert.ok(failed, "dummy-key synthesis must fail in some observable way"); +}); + +function tryCall(fn) { + try { + fn(); + return "returned"; + } catch { + return "threw"; + } +} + +test("boundary / mark / viseme callback registration does not throw", () => { + // Registration-only: synthesis outcome depends on network reachability, + // so this test asserts the trampolines wire up (and stay silent when + // nothing fires), not delivery. Delivery is covered by the engine + // suites and live tests. + const c = makeClient(); + let boundaries = 0; + let marks = 0; + let visemes = 0; + c.on("boundary", (ev) => { + boundaries++; + assert.equal(typeof ev.word, "string"); + assert.equal(typeof ev.estimated, "boolean"); + }); + c.on("mark", () => marks++); + c.on("viseme", () => visemes++); + c.close(); + assert.equal(boundaries, 0); + assert.equal(marks, 0); + assert.equal(visemes, 0); +}); + +test("loadLibrary with explicit path rejects a bad path clearly", () => { + assert.throws(() => loadLibrary("/nonexistent/lib.so"), /not found|Cannot open/); +}); diff --git a/bindings/python/tts_wrapper.py b/bindings/python/tts_wrapper.py index 9434ae7..7b0a69f 100644 --- a/bindings/python/tts_wrapper.py +++ b/bindings/python/tts_wrapper.py @@ -32,7 +32,22 @@ None, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.c_void_p ) BOUNDARY_CB = ctypes.CFUNCTYPE( - None, ctypes.c_char_p, ctypes.c_float, ctypes.c_float, ctypes.c_void_p + None, + ctypes.c_char_p, # word + ctypes.c_int32, # char_offset (-1 when unknown) + ctypes.c_int32, # char_len (-1 when unknown) + ctypes.c_float, # start_s + ctypes.c_float, # end_s + ctypes.c_int32, # estimated (1 = proportional estimate, 0 = measured) + ctypes.c_void_p, # userdata +) +MARK_CB = ctypes.CFUNCTYPE( + None, + ctypes.c_char_p, # name + ctypes.c_int32, # char_offset (-1 when unknown) + ctypes.c_float, # start_s + ctypes.c_float, # end_s + ctypes.c_void_p, # userdata ) VOID_CB = ctypes.CFUNCTYPE(None, ctypes.c_void_p) ERROR_CB = ctypes.CFUNCTYPE(None, ctypes.c_char_p, ctypes.c_void_p) @@ -293,8 +308,15 @@ def on_boundary(self, callback: Optional[Callable[[str, float, float], None]]) - return @BOUNDARY_CB - def _cb(word, start, end, _userdata): - callback(word.decode() if word else "", start, end) + def _cb(word, char_offset, char_len, start, end, estimated, _userdata): + callback( + word.decode() if word else "", + char_offset, + char_len, + start, + end, + bool(estimated), + ) self._boundary_cb_ref = _cb self._lib.tts_set_on_boundary(self._ctx, _cb, None) diff --git a/bindings/swift/Package.swift b/bindings/swift/Package.swift new file mode 100644 index 0000000..505dd3c --- /dev/null +++ b/bindings/swift/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version:5.9 +import PackageDescription + +// Build the Rust library first and point the build at it: +// cargo build --no-default-features --features avsynth,cloud +// TTS_WRAPPER_LIB_DIR=$PWD/target/debug swift test +let libDir = ProcessInfo.processInfo.environment["TTS_WRAPPER_LIB_DIR"] ?? "../target/debug" + +let package = Package( + name: "RustTtsWrapper", + products: [ + .library(name: "RustTtsWrapper", targets: ["RustTtsWrapper"]), + ], + targets: [ + // C shim over the cbindgen header (single source of truth: the + // header is a symlink to ../../include/tts_wrapper.h — CI verifies + // it matches). Linking is runtime-agnostic: the dylib must be on + // the loader path at run time (or use the staticlib). + .target( + name: "CRustTtsWrapper", + linkerSettings: [ + .linkedLibrary("rust_tts_wrapper"), + .unsafeFlags(["-L\(libDir)"]), + ] + ), + .target( + name: "RustTtsWrapper", + dependencies: ["CRustTtsWrapper"], + path: "." + ), + .testTarget( + name: "RustTtsWrapperTests", + dependencies: ["RustTtsWrapper"], + path: "Tests/RustTtsWrapperTests" + ), + ] +) diff --git a/bindings/swift/Sources/CRustTtsWrapper/include/module.modulemap b/bindings/swift/Sources/CRustTtsWrapper/include/module.modulemap new file mode 100644 index 0000000..b984e14 --- /dev/null +++ b/bindings/swift/Sources/CRustTtsWrapper/include/module.modulemap @@ -0,0 +1,4 @@ +module CRustTtsWrapper { + header "tts_wrapper.h" + export * +} diff --git a/bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h b/bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h new file mode 120000 index 0000000..486106f --- /dev/null +++ b/bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h @@ -0,0 +1 @@ +../../include/tts_wrapper.h \ No newline at end of file diff --git a/bindings/swift/Tests/RustTtsWrapperTests/RustTtsWrapperAbiTests.swift b/bindings/swift/Tests/RustTtsWrapperTests/RustTtsWrapperAbiTests.swift new file mode 100644 index 0000000..11c04ac --- /dev/null +++ b/bindings/swift/Tests/RustTtsWrapperTests/RustTtsWrapperAbiTests.swift @@ -0,0 +1,101 @@ +// ABI conformance tests for the Swift binding — mirrors bindings/c +// (the C acceptance harness). Requires the Rust dylib built and +// TTS_WRAPPER_LIB_DIR set at build time (see Package.swift). + +import XCTest +@testable import RustTtsWrapper + +final class RustTtsWrapperAbiTests: XCTestCase { + private func makeClient() throws -> TtsClient { + try TtsClient( + engineId: "openai", + credentials: ["apiKey": "dummy-key-for-swift-tests"] + ) + } + + func testEngineEnumerationMatchesCount() throws { + let count = TtsClient.engineCount() + XCTAssertGreaterThan(count, 0) + + let engines = try TtsClient.listEngines() + XCTAssertEqual(engines.count, count) + for e in engines { + XCTAssertFalse(e.id.isEmpty) + XCTAssertFalse(e.name.isEmpty) + } + XCTAssertTrue(engines.contains { $0.id == "openai" }) + } + + func testCreateCloseRoundTrip() throws { + let c = try makeClient() + c.close() + c.close() // idempotent + XCTAssertThrowsError(try c.speak("x")) + } + + func testCreateFailureSurfacesGlobalError() { + XCTAssertThrowsError(try TtsClient(engineId: "no-such-engine")) { error in + XCTAssertTrue("\(error)".contains("no-such-engine")) + } + } + + func testManyClientsLiveSimultaneously() throws { + let clients = try (0..<8).map { _ in try makeClient() } + for c in clients { _ = try c.getVoices() } + for c in clients { c.close() } + } + + func testSettersAcceptTypicalValues() throws { + let c = try makeClient() + c.setVoice("alloy") + c.setVoice("") + c.setRate(1.5) + c.setPitch(0.8) + c.setVolume(0.9) + c.stop() + c.pause() + c.resume() + c.close() + } + + func testGetVoicesReturnsArray_EmptyOfflineIsFine() throws { + let c = try makeClient() + defer { c.close() } + let voices = try c.getVoices() + for v in voices { XCTAssertFalse(v.id.isEmpty) } + } + + func testDummyKeySynthesisFailsObservably() throws { + let c = try makeClient() + defer { c.close() } + XCTAssertThrowsError(try c.speakSync("hello swift")) + XCTAssertNotNil(c.getLastError()) + XCTAssertThrowsError(try c.synthToBytes("hello swift")) + } + + func testCallbackRegistrationDoesNotThrow() throws { + let c = try makeClient() + defer { c.close() } + + c.setOnAudio { _ in } + c.setOnBoundary { word, charOffset, charLen, start, end, estimated in + _ = (word, charOffset, charLen, start, end, estimated) + } + c.setOnMark { name, charOffset, start, end in + _ = (name, charOffset, start, end) + } + c.setOnViseme { id, offsetSec in _ = (id, offsetSec) } + c.setOnStart {} + c.setOnEnd {} + c.setOnError { _ in } + + // Clearing is a silent no-op. + c.setOnAudio(nil) + c.setOnBoundary(nil) + c.setOnMark(nil) + c.setOnViseme(nil) + c.setOnStart(nil) + c.setOnEnd(nil) + c.setOnError(nil) + } +} diff --git a/bindings/swift/TtsClient.swift b/bindings/swift/TtsClient.swift index bfe0bfe..284da32 100644 --- a/bindings/swift/TtsClient.swift +++ b/bindings/swift/TtsClient.swift @@ -1,8 +1,12 @@ import Foundation -// Importing the C header that cbindgen generates at build time. When you -// embed this file into an Xcode / SwiftPM project that also builds the Rust -// staticlib, the module name matches the crate name (`rust_tts_wrapper`). -#if canImport(rust_tts_wrapper) +// Two supported integrations: +// * SwiftPM (this package): the CRustTtsWrapper C target wraps the +// cbindgen header (symlinked from include/tts_wrapper.h). +// * Xcode: embed TtsClient.swift in a project that also builds the Rust +// staticlib and imports the header under the crate's module name. +#if canImport(CRustTtsWrapper) +import CRustTtsWrapper +#elseif canImport(rust_tts_wrapper) import rust_tts_wrapper #endif @@ -49,7 +53,19 @@ public struct TtsError: Error, CustomStringConvertible { /// Strongly-typed closure aliases. public typealias AudioCallback = @Sendable (Data) -> Void -public typealias BoundaryCallback = @Sendable (_ word: String, _ start: Double, _ end: Double) -> Void +/// - Parameters: +/// - word: the word being spoken +/// - charOffset/charLen: source-text position (-1 when unknown) +/// - start/end: audio position in seconds +/// - estimated: true = proportional estimate; false = measured timings +public typealias BoundaryCallback = @Sendable ( + _ word: String, _ charOffset: Int32, _ charLen: Int32, + _ start: Double, _ end: Double, _ estimated: Bool +) -> Void +public typealias MarkCallback = @Sendable ( + _ name: String, _ charOffset: Int32, _ start: Double, _ end: Double +) -> Void +public typealias VisemeCallback = @Sendable (_ visemeId: Int32, _ offsetSec: Double) -> Void public typealias LifecycleCallback = @Sendable () -> Void public typealias ErrorCallback = @Sendable (_ error: String) -> Void @@ -63,6 +79,8 @@ public final class TtsClient: @unchecked Sendable { // remains valid for as long as the client lives. private var audioBox: CallbackBox? private var boundaryBox: CallbackBox? + private var markBox: CallbackBox? + private var visemeBox: CallbackBox? private var startBox: CallbackBox? private var endBox: CallbackBox? private var errorBox: CallbackBox? @@ -72,7 +90,7 @@ public final class TtsClient: @unchecked Sendable { .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" ctx = engineId.withCString { enginePtr in credsJson.withCString { credsPtr in - rust_tts_wrapper.tts_create(enginePtr, credsPtr) + tts_create(enginePtr, credsPtr) } } if ctx == nil { @@ -83,7 +101,7 @@ public final class TtsClient: @unchecked Sendable { deinit { close() } public func close() { - if let ctx { rust_tts_wrapper.tts_destroy(ctx) } + if let ctx { tts_destroy(ctx) } ctx = nil } @@ -92,14 +110,14 @@ public final class TtsClient: @unchecked Sendable { /// Speak asynchronously (engine-defined). public func speak(_ text: String) throws { guard let ctx else { throw TtsError("client closed") } - let rc = text.withCString { rust_tts_wrapper.tts_speak(ctx, $0) } + let rc = text.withCString { tts_speak(ctx, $0) } if rc != 0 { throw TtsError(getLastError() ?? "speak failed") } } /// Speak synchronously (block until done). public func speakSync(_ text: String) throws { guard let ctx else { throw TtsError("client closed") } - let rc = text.withCString { rust_tts_wrapper.tts_speak_sync(ctx, $0) } + let rc = text.withCString { tts_speak_sync(ctx, $0) } if rc != 0 { throw TtsError(getLastError() ?? "speak_sync failed") } } @@ -109,11 +127,11 @@ public final class TtsClient: @unchecked Sendable { var bufPtr: UnsafeMutablePointer? var length: Int = 0 let rc = text.withCString { textPtr in - rust_tts_wrapper.tts_synth_to_bytes(ctx, textPtr, &bufPtr, &length) + tts_synth_to_bytes(ctx, textPtr, &bufPtr, &length) } if rc != 0 { throw TtsError(getLastError() ?? "synth_to_bytes failed") } guard let buf = bufPtr, length > 0 else { return Data() } - defer { rust_tts_wrapper.tts_free_bytes(buf, length) } + defer { tts_free_bytes(buf, length) } return Data(bytes: buf, count: length) } @@ -121,36 +139,36 @@ public final class TtsClient: @unchecked Sendable { public func stop() { guard let ctx else { return } - rust_tts_wrapper.tts_stop(ctx) + tts_stop(ctx) } public func pause() { guard let ctx else { return } - rust_tts_wrapper.tts_pause(ctx) + tts_pause(ctx) } public func resume() { guard let ctx else { return } - rust_tts_wrapper.tts_resume(ctx) + tts_resume(ctx) } // --- per-instance settings ---------------------------------------- public func setVoice(_ voiceId: String) { guard let ctx else { return } - voiceId.withCString { rust_tts_wrapper.tts_set_voice(ctx, $0) } + voiceId.withCString { tts_set_voice(ctx, $0) } } public func setRate(_ rate: Float) { guard let ctx else { return } - rust_tts_wrapper.tts_set_rate(ctx, rate) + tts_set_rate(ctx, rate) } public func setPitch(_ pitch: Float) { guard let ctx else { return } - rust_tts_wrapper.tts_set_pitch(ctx, pitch) + tts_set_pitch(ctx, pitch) } public func setVolume(_ volume: Float) { guard let ctx else { return } - rust_tts_wrapper.tts_set_volume(ctx, volume) + tts_set_volume(ctx, volume) } // --- callbacks ----------------------------------------------------- @@ -163,7 +181,7 @@ public final class TtsClient: @unchecked Sendable { audioBox = box // Bridge a plain C entry point back into the Swift closure. let opaque = Unmanaged.passUnretained(box).toOpaque() - rust_tts_wrapper.tts_set_on_audio( + tts_set_on_audio( ctx, { bytes, len, userdata in guard let bytes, len > 0, let userdata else { return } @@ -175,7 +193,7 @@ public final class TtsClient: @unchecked Sendable { ) } else { audioBox = nil - rust_tts_wrapper.tts_set_on_audio(ctx, nil, nil) + tts_set_on_audio(ctx, nil, nil) } } @@ -186,19 +204,64 @@ public final class TtsClient: @unchecked Sendable { let box = CallbackBox(callback) boundaryBox = box let opaque = Unmanaged.passUnretained(box).toOpaque() - rust_tts_wrapper.tts_set_on_boundary( + tts_set_on_boundary( ctx, - { wordPtr, start, end, userdata in + { wordPtr, charOffset, charLen, start, end, estimated, userdata in guard let userdata else { return } let box = Unmanaged>.fromOpaque(userdata).takeUnretainedValue() let word = wordPtr.map { String(cString: $0) } ?? "" - box.callback(word, Double(start), Double(end)) + box.callback(word, charOffset, charLen, Double(start), Double(end), estimated != 0) }, opaque ) } else { boundaryBox = nil - rust_tts_wrapper.tts_set_on_boundary(ctx, nil, nil) + tts_set_on_boundary(ctx, nil, nil) + } + } + + /// Register a mark/bookmark callback (SSML ``). Pass `nil` to clear. + public func setOnMark(_ callback: MarkCallback?) { + guard let ctx else { return } + if let callback { + let box = CallbackBox(callback) + markBox = box + let opaque = Unmanaged.passUnretained(box).toOpaque() + tts_set_on_mark( + ctx, + { namePtr, charOffset, start, end, userdata in + guard let userdata else { return } + let box = Unmanaged>.fromOpaque(userdata).takeUnretainedValue() + let name = namePtr.map { String(cString: $0) } ?? "" + box.callback(name, charOffset, Double(start), Double(end)) + }, + opaque + ) + } else { + markBox = nil + tts_set_on_mark(ctx, nil, nil) + } + } + + /// Register a viseme callback for lip-sync. Pass `nil` to clear. + public func setOnViseme(_ callback: VisemeCallback?) { + guard let ctx else { return } + if let callback { + let box = CallbackBox(callback) + visemeBox = box + let opaque = Unmanaged.passUnretained(box).toOpaque() + tts_set_on_viseme( + ctx, + { visemeId, offsetSec, userdata in + guard let userdata else { return } + let box = Unmanaged>.fromOpaque(userdata).takeUnretainedValue() + box.callback(visemeId, Double(offsetSec)) + }, + opaque + ) + } else { + visemeBox = nil + tts_set_on_viseme(ctx, nil, nil) } } @@ -209,7 +272,7 @@ public final class TtsClient: @unchecked Sendable { let box = CallbackBox(callback) startBox = box let opaque = Unmanaged.passUnretained(box).toOpaque() - rust_tts_wrapper.tts_set_on_start( + tts_set_on_start( ctx, { userdata in guard let userdata else { return } @@ -220,7 +283,7 @@ public final class TtsClient: @unchecked Sendable { ) } else { startBox = nil - rust_tts_wrapper.tts_set_on_start(ctx, nil, nil) + tts_set_on_start(ctx, nil, nil) } } @@ -231,7 +294,7 @@ public final class TtsClient: @unchecked Sendable { let box = CallbackBox(callback) endBox = box let opaque = Unmanaged.passUnretained(box).toOpaque() - rust_tts_wrapper.tts_set_on_end( + tts_set_on_end( ctx, { userdata in guard let userdata else { return } @@ -242,7 +305,7 @@ public final class TtsClient: @unchecked Sendable { ) } else { endBox = nil - rust_tts_wrapper.tts_set_on_end(ctx, nil, nil) + tts_set_on_end(ctx, nil, nil) } } @@ -253,7 +316,7 @@ public final class TtsClient: @unchecked Sendable { let box = CallbackBox(callback) errorBox = box let opaque = Unmanaged.passUnretained(box).toOpaque() - rust_tts_wrapper.tts_set_on_error( + tts_set_on_error( ctx, { errorPtr, userdata in guard let userdata else { return } @@ -265,7 +328,7 @@ public final class TtsClient: @unchecked Sendable { ) } else { errorBox = nil - rust_tts_wrapper.tts_set_on_error(ctx, nil, nil) + tts_set_on_error(ctx, nil, nil) } } @@ -276,10 +339,10 @@ public final class TtsClient: @unchecked Sendable { guard let ctx else { throw TtsError("client closed") } var arr: UnsafeMutablePointer? var count: Int32 = 0 - let rc = rust_tts_wrapper.tts_get_voices(ctx, &arr, &count) + let rc = tts_get_voices(ctx, &arr, &count) if rc != 0 { throw TtsError(getLastError() ?? "get_voices failed") } guard let arr, count > 0 else { return [] } - defer { rust_tts_wrapper.tts_free_voices(arr, count) } + defer { tts_free_voices(arr, count) } var voices: [TtsVoice] = [] voices.reserveCapacity(Int(count)) @@ -300,12 +363,12 @@ public final class TtsClient: @unchecked Sendable { public static func listEngines() throws -> [TtsEngineInfo] { var arr: UnsafeMutablePointer? var count: Int32 = 0 - let rc = rust_tts_wrapper.tts_get_engines(&arr, &count) + let rc = tts_get_engines(&arr, &count) if rc != 0 { throw TtsError(getGlobalLastError() ?? "tts_get_engines failed") } guard let arr, count > 0 else { return [] } - defer { rust_tts_wrapper.tts_free_engines(arr, count) } + defer { tts_free_engines(arr, count) } var engines: [TtsEngineInfo] = [] engines.reserveCapacity(Int(count)) @@ -325,7 +388,7 @@ public final class TtsClient: @unchecked Sendable { /// Number of engines available. Convenience over `listEngines()`. public static func engineCount() -> Int { - Int(rust_tts_wrapper.tts_get_engine_count()) + Int(tts_get_engine_count()) } // --- error handling ------------------------------------------------ @@ -333,13 +396,13 @@ public final class TtsClient: @unchecked Sendable { /// Last error for this context, or `nil` if none. public func getLastError() -> String? { guard let ctx else { return nil } - guard let ptr = rust_tts_wrapper.tts_get_last_error(ctx) else { return nil } + guard let ptr = tts_get_last_error(ctx) else { return nil } return String(cString: ptr) } /// Global last error (used when no context exists, e.g. `tts_create`). public static func getGlobalLastError() -> String? { - guard let ptr = rust_tts_wrapper.tts_get_last_error(nil) else { return nil } + guard let ptr = tts_get_last_error(nil) else { return nil } return String(cString: ptr) } @@ -362,7 +425,7 @@ private final class CallbackBox { // module links against the rust-tts-wrapper staticlib and imports the C // header; we declare fallback aliases here so the file compiles even when // the import isn't found. -#if !canImport(rust_tts_wrapper) +#if !canImport(CRustTtsWrapper) && !canImport(rust_tts_wrapper) @_cdecl("tts_create") public func ttsCreate(_ engineId: UnsafePointer?, _ creds: UnsafePointer?) -> OpaquePointer? { nil } @_cdecl("tts_destroy") public func ttsDestroy(_ ctx: OpaquePointer?) {} @_cdecl("tts_speak") public func ttsSpeak(_ ctx: OpaquePointer?, _ text: UnsafePointer?) -> Int32 { -1 } diff --git a/tests/ffi_conformance.rs b/tests/ffi_conformance.rs new file mode 100644 index 0000000..8534f9b --- /dev/null +++ b/tests/ffi_conformance.rs @@ -0,0 +1,205 @@ +//! ABI conformance suite. +//! +//! Symbol-level contract tests for the C ABI that every language binding +//! (C, Python, .NET, Swift, Node) depends on. Complements +//! `ffi_lifecycle.rs` (per-symbol lifecycle) and `ffi_safety.rs` +//! (hardening) with the cross-cutting behaviours bindings actually lean +//! on: multi-context lifetimes, the error surface, callback +//! replacement, and the full setter surface. +//! +//! Uses the same offline-deterministic strategy as the lifecycle suite: +//! a cloud engine (`openai`) constructs without network access and +//! fails synthesis deterministically with a dummy key. + +#![allow(clippy::all, clippy::pedantic)] + +use rust_tts_wrapper::tts_ctx; +use rust_tts_wrapper::{ + tts_create, tts_destroy, tts_get_last_error, tts_set_on_audio, tts_set_on_boundary, + tts_set_on_mark, tts_speak_ssml, tts_speak_sync, tts_synth_to_bytes, +}; +use std::ffi::{c_void, CString}; +use std::os::raw::{c_char, c_int}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn make_ctx() -> *mut tts_ctx { + let id = CString::new("openai").unwrap(); + let creds = CString::new(r#"{"apiKey":"dummy-key-for-conformance"}"#).unwrap(); + let ctx = tts_create(id.as_ptr(), creds.as_ptr()); + assert!(!ctx.is_null(), "tts_create(openai) must succeed offline"); + ctx +} + +// --------------------------------------------------------------------------- +// Multi-context lifetimes +// --------------------------------------------------------------------------- + +#[test] +fn conformance_many_contexts_live_simultaneously() { + // Bindings (and hosts like screen readers) hold one ctx per engine + // instance; creation must not leak global state between contexts and + // destruction order must not matter. + let mut ctxs: Vec<*mut tts_ctx> = (0..16).map(|_| make_ctx()).collect(); + for ctx in &ctxs { + assert!(!ctx.is_null()); + } + // Destroy in reverse order, then a fresh batch in forward order. + ctxs.reverse(); + for ctx in ctxs { + tts_destroy(ctx); + } + + let mut second: Vec<*mut tts_ctx> = (0..4).map(|_| make_ctx()).collect(); + for ctx in second.drain(..) { + tts_destroy(ctx); + } +} + +#[test] +fn conformance_context_isolation_last_error() { + // last_error is per-context: an error recorded on ctx A must not be + // visible on a fresh ctx B. + let a = make_ctx(); + let b = make_ctx(); + + // Force a failure on A (dummy key → deterministic offline error). + let text = CString::new("isolation").unwrap(); + let rc_a = tts_synth_to_bytes(a, text.as_ptr(), std::ptr::null_mut(), std::ptr::null_mut()); + assert_ne!(rc_a, 0, "dummy-key synth must fail"); + + // B was created after A but before A failed. Contract: null (or, + // if the global fallback fired, an unrelated string) — never A's + // error. The returned pointer is owned by the ctx — borrow only. + let err_b = tts_get_last_error(b); + if !err_b.is_null() { + // SAFETY: valid C string returned by tts_get_last_error. + let b_msg = unsafe { std::ffi::CStr::from_ptr(err_b) }.to_bytes(); + assert!( + b_msg.is_empty(), + "ctx B must not observe ctx A's error (got {b_msg:?})" + ); + } + + tts_destroy(a); + tts_destroy(b); +} + +// --------------------------------------------------------------------------- +// Error surface +// --------------------------------------------------------------------------- + +#[test] +fn conformance_failed_synth_populates_last_error() { + let ctx = make_ctx(); + let text = CString::new("conformance error surface").unwrap(); + + let mut bytes: *mut u8 = std::ptr::null_mut(); + let mut len: usize = 0; + let rc = tts_synth_to_bytes(ctx, text.as_ptr(), &mut bytes, &mut len); + assert_ne!(rc, 0, "offline dummy-key synthesis must fail"); + assert!(bytes.is_null(), "no buffer must be handed out on failure"); + assert_eq!(len, 0); + + let err = tts_get_last_error(ctx); + assert!(!err.is_null(), "failure must populate last_error"); + // Borrowed pointer — do not free; just read. + // SAFETY: valid C string returned by tts_get_last_error. + let msg = unsafe { std::ffi::CStr::from_ptr(err) } + .to_string_lossy() + .to_string(); + assert!(!msg.is_empty(), "last_error must be a non-empty message"); + assert!(bytes.is_null()); + + tts_destroy(ctx); +} + +#[test] +fn conformance_speak_ssml_valid_ctx_fails_cleanly_offline() { + let ctx = make_ctx(); + let ssml = CString::new("conformance ssml").unwrap(); + let rc = tts_speak_ssml(ctx, ssml.as_ptr()); + assert_ne!(rc, 0, "dummy-key SSML synthesis must fail, not crash"); + + let text = CString::new("plain").unwrap(); + let rc_sync = tts_speak_sync(ctx, text.as_ptr()); + assert_ne!(rc_sync, 0, "dummy-key sync speak must fail, not crash"); + + tts_destroy(ctx); +} + +// --------------------------------------------------------------------------- +// Callback surface +// --------------------------------------------------------------------------- + +static MARK_CALLS: AtomicUsize = AtomicUsize::new(0); +extern "C" fn mark_cb( + _name: *const c_char, + _char_offset: i32, + _start: f32, + _end: f32, + _userdata: *mut c_void, +) { + MARK_CALLS.fetch_add(1, Ordering::SeqCst); +} + +#[test] +fn conformance_mark_callback_register_clear_and_null_ctx() { + let ctx = make_ctx(); + + tts_set_on_mark(ctx, Some(mark_cb), std::ptr::null_mut()); + // Replace with None (clear) — must be a silent no-op, not an error. + tts_set_on_mark(ctx, None, std::ptr::null_mut()); + // Null ctx is accepted as a no-op for every setter. + tts_set_on_mark(std::ptr::null_mut(), Some(mark_cb), std::ptr::null_mut()); + tts_set_on_mark(std::ptr::null_mut(), None, std::ptr::null_mut()); + + assert_eq!(MARK_CALLS.load(Ordering::SeqCst), 0); + tts_destroy(ctx); +} + +#[test] +fn conformance_callbacks_can_be_replaced_in_place() { + // Re-registering over a live callback must not double-fire or panic; + // the last registration wins. Verified via the audio callback with + // distinct userdata sentinels. + static SEEN: AtomicUsize = AtomicUsize::new(0); + extern "C" fn audio_a(_d: *const u8, _s: usize, _u: *mut c_void) { + SEEN.store(0xA, Ordering::SeqCst); + } + extern "C" fn audio_b(_d: *const u8, _s: usize, _u: *mut c_void) { + SEEN.store(0xB, Ordering::SeqCst); + } + + let ctx = make_ctx(); + tts_set_on_audio(ctx, Some(audio_a), std::ptr::null_mut()); + tts_set_on_audio(ctx, Some(audio_b), std::ptr::null_mut()); + tts_set_on_audio(ctx, None, std::ptr::null_mut()); + tts_set_on_audio(ctx, Some(audio_a), std::ptr::null_mut()); + tts_destroy(ctx); +} + +#[test] +fn conformance_boundary_callback_full_signature_compiles() { + // The consolidated boundary callback signature every binding + // marshals: (word, char_offset, char_len, start_s, end_s, + // estimated, userdata). Compile-level contract + registration + // round-trip; live delivery is covered by the engine suites. + static BOUNDARY_REGISTRATIONS: AtomicUsize = AtomicUsize::new(0); + extern "C" fn boundary_cb( + _word: *const c_char, + _char_offset: i32, + _char_len: i32, + _start_s: f32, + _end_s: f32, + _estimated: c_int, + _userdata: *mut c_void, + ) { + BOUNDARY_REGISTRATIONS.fetch_add(1, Ordering::SeqCst); + } + + let ctx = make_ctx(); + tts_set_on_boundary(ctx, Some(boundary_cb), std::ptr::null_mut()); + tts_set_on_boundary(ctx, None, std::ptr::null_mut()); + tts_destroy(ctx); + assert_eq!(BOUNDARY_REGISTRATIONS.load(Ordering::SeqCst), 0); +} From 0a2a9cc2af25a109cd665db7447bcca4947df5ba Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 21 Aug 2026 08:27:34 +0000 Subject: [PATCH 2/7] =?UTF-8?q?bindings:=20fix=20CI=20=E2=80=94=20using=20?= =?UTF-8?q?Xunit,=20vendored=20Swift=20header=20(symlinks=20don't=20surviv?= =?UTF-8?q?e=20checkout)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/bindings.yml | 4 +- bindings/dotnet/tests/AbiConformanceTests.cs | 2 + bindings/swift/Package.swift | 8 +- .../CRustTtsWrapper/include/tts_wrapper.h | 404 +++++++++++++++++- 4 files changed, 411 insertions(+), 7 deletions(-) mode change 120000 => 100644 bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h diff --git a/.github/workflows/bindings.yml b/.github/workflows/bindings.yml index 823c38f..f8898ae 100644 --- a/.github/workflows/bindings.yml +++ b/.github/workflows/bindings.yml @@ -112,8 +112,8 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Build library (debug) run: cargo build --no-default-features --features avsynth,cloud - - name: Verify header symlink resolves - run: test -f bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h + - 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: diff --git a/bindings/dotnet/tests/AbiConformanceTests.cs b/bindings/dotnet/tests/AbiConformanceTests.cs index a607efb..cd28ed8 100644 --- a/bindings/dotnet/tests/AbiConformanceTests.cs +++ b/bindings/dotnet/tests/AbiConformanceTests.cs @@ -5,6 +5,8 @@ // cargo build --no-default-features --features system,cloud (Linux) // and located via TTS_WRAPPER_LIB, or on the OS loader's path. +using Xunit; + namespace RustTtsWrapper.Bindings.Tests; public class AbiConformanceTests diff --git a/bindings/swift/Package.swift b/bindings/swift/Package.swift index 505dd3c..25a335e 100644 --- a/bindings/swift/Package.swift +++ b/bindings/swift/Package.swift @@ -12,10 +12,10 @@ let package = Package( .library(name: "RustTtsWrapper", targets: ["RustTtsWrapper"]), ], targets: [ - // C shim over the cbindgen header (single source of truth: the - // header is a symlink to ../../include/tts_wrapper.h — CI verifies - // it matches). Linking is runtime-agnostic: the dylib must be on - // the loader path at run time (or use the staticlib). + // C shim over the cbindgen header. The header is a committed copy + // of include/tts_wrapper.h — CI diffs the two to prevent drift; + // refresh it with: + // cp ../../include/tts_wrapper.h Sources/CRustTtsWrapper/include/ .target( name: "CRustTtsWrapper", linkerSettings: [ diff --git a/bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h b/bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h deleted file mode 120000 index 486106f..0000000 --- a/bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h +++ /dev/null @@ -1 +0,0 @@ -../../include/tts_wrapper.h \ No newline at end of file diff --git a/bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h b/bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h new file mode 100644 index 0000000..ac34711 --- /dev/null +++ b/bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h @@ -0,0 +1,403 @@ +#ifndef TTS_WRAPPER_H +#define TTS_WRAPPER_H + +/* Auto-generated. Do not edit. */ + +#include +#include + +typedef struct tts_ctx tts_ctx; + +/** + * C-compatible voice descriptor returned by [`tts_get_voices`](crate::tts_get_voices). + */ +typedef struct tts_voice { + /** + * Voice identifier (owned C string). + */ + char *id; + /** + * Voice name (owned C string). + */ + char *name; + /** + * Language tag (owned C string). + */ + char *language; + /** + * Gender (owned C string). + */ + char *gender; + /** + * Engine identifier (owned C string). + */ + char *engine; +} tts_voice; + +/** + * Opaque context holding an engine instance and its per-instance settings. + */ +typedef void (*CAudioCb)(const uint8_t*, uintptr_t, void*); + +/** + * Word-boundary callback: + * cb(word, char_offset, char_len, start_s, end_s, estimated, userdata). + * char_offset/char_len are -1 when unknown. `estimated` is 1 when the + * timings are proportional estimates (unpatched voice, wpm model), 0 + * when measured (floravox duration tensor, cloud provider timings). + */ +typedef void (*CBoundaryCb)(const char*, int32_t, int32_t, float, float, int32_t, void*); + +/** + * Mark/bookmark callback: cb(name, char_offset, start_s, end_s, userdata). + * char_offset is -1 when unknown; start/end are the measured (or + * estimated) audio position the mark fires at. + */ +typedef void (*CMarkCb)(const char*, int32_t, float, float, void*); + +typedef void (*CVisemeCb)(int32_t, float, void*); + +typedef void (*CVoidCb)(void*); + +typedef void (*CErrorCb)(const char*, void*); + +/** + * C-compatible engine descriptor returned by [`tts_get_engines`](crate::tts_get_engines). + */ +typedef struct tts_engine_info { + /** + * Engine identifier (owned C string). + */ + char *id; + /** + * Engine name (owned C string). + */ + char *name; + /** + * Whether credentials are required. + */ + bool needs_credentials; + /** + * JSON array of credential key names (owned C string). + */ + char *credential_keys_json; +} tts_engine_info; + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/** + * Create a new TTS engine instance. + * + * Returns an opaque context pointer on success, or null on failure. + * Call [`tts_get_last_error`] to retrieve the error message on failure. + * + * # Safety + * + * `engine_id` must be a valid null-terminated C string. + * `credentials_json` may be null or a valid null-terminated JSON string. + */ +struct tts_ctx *tts_create(const char *engine_id, const char *credentials_json); + +/** + * Destroy a TTS context and free all associated resources. + * + * Attempts to stop any in-progress speech before dropping the engine so the + * underlying resources (speech-dispatcher connection, COM objects, etc.) get + * a chance to clean up + * + * # Safety + * + * `ctx` must be a pointer previously returned by [`tts_create`], + * or null (no-op). + */ +void tts_destroy(struct tts_ctx *ctx); + +/** + * Speak `text` asynchronously using the engine in `ctx`. + * + * Returns 0 on success, -1 on failure. + * + * # Safety + * + * `ctx` must be a valid pointer from [`tts_create`]. + * `text` must be a valid null-terminated C string. + */ +int32_t tts_speak(struct tts_ctx *ctx, const char *text); + +/** + * Speak pre-built SSML using the engine in `ctx`. + * + * The SSML is passed directly to the engine without SpeechMarkdown + * conversion or rate/pitch/volume wrapping. Callers are responsible + * for embedding all prosody in the SSML. + * + * Returns 0 on success, -1 on failure. + * + * # Safety + * + * `ctx` must be a valid pointer from [`tts_create`]. + * `ssml` must be a valid null-terminated C string. + */ +int32_t tts_speak_ssml(struct tts_ctx *ctx, const char *ssml); + +/** + * Speak `text` synchronously (blocks until complete). + * + * Returns 0 on success, -1 on failure. + * + * # Safety + * + * `ctx` must be a valid pointer from [`tts_create`]. + * `text` must be a valid null-terminated C string. + */ +int32_t tts_speak_sync(struct tts_ctx *ctx, const char *text); + +/** + * Stop any in-progress speech. + * + * # Safety + * + * `ctx` must be a valid pointer from [`tts_create`]. + */ +void tts_stop(struct tts_ctx *ctx); + +/** + * Retrieve the list of available voices for the engine. + * + * On success, writes a heap-allocated array to `*out_voices` and its length + * to `*out_count`. Caller must free with [`tts_free_voices`]. + * + * Returns 0 on success, -1 on failure. + * + * # Safety + * + * `ctx` must be valid. `out_voices` and `out_count` must be non-null. + */ +int32_t tts_get_voices(struct tts_ctx *ctx, struct tts_voice **out_voices, int32_t *out_count); + +/** + * Free a voice array previously returned by [`tts_get_voices`]. + * + * # Safety + * + * `voices` must be a pointer from `tts_get_voices` with the matching `count`. + */ +void tts_free_voices(struct tts_voice *voices, int32_t count); + +/** + * Set the voice for subsequent speak calls. + * + * # Safety + * + * `ctx` must be valid. `voice_id` must be a valid null-terminated C string. + */ +void tts_set_voice(struct tts_ctx *ctx, const char *voice_id); + +/** + * Set the speech rate (1.0 = normal). + * + * # Safety + * + * `ctx` must be valid. + */ +void tts_set_rate(struct tts_ctx *ctx, float rate); + +/** + * Set the speech pitch (1.0 = normal). + * + * # Safety + * + * `ctx` must be valid. + */ +void tts_set_pitch(struct tts_ctx *ctx, float pitch); + +/** + * Set the speech volume (1.0 = normal). + * + * # Safety + * + * `ctx` must be valid. + */ +void tts_set_volume(struct tts_ctx *ctx, float volume); + +/** + * Set the callback for streaming audio chunks. + * + * # Safety + * `ctx` must be valid. + */ +void tts_set_on_audio(struct tts_ctx *ctx, CAudioCb cb, void *userdata); + +/** + * Set the word-boundary callback: + * cb(word, char_offset, char_len, start_s, end_s, estimated, userdata). + * char_offset/char_len are -1 when unknown. `estimated` is 1 when the + * timings are proportional estimates (unpatched voice, wpm model), 0 + * when measured (floravox duration tensor, cloud provider timings). + * + * # Safety + * `ctx` must be valid. + */ +void tts_set_on_boundary(struct tts_ctx *ctx, CBoundaryCb cb, void *userdata); + +/** + * Set the mark/bookmark callback: cb(name, char_offset, start_s, end_s, userdata). + * Fires for ``/`` SSML tags at their measured audio + * position on engines that report them (floravox). + * + * # Safety + * `ctx` must be valid. + */ +void tts_set_on_mark(struct tts_ctx *ctx, CMarkCb cb, void *userdata); + +/** + * Viseme callback for lip-sync / facial animation. + * cb(viseme_id, audio_offset_sec, userdata) + * + * # Safety + * `ctx` must be valid. + */ +void tts_set_on_viseme(struct tts_ctx *ctx, CVisemeCb cb, void *userdata); + +/** + * Set the callback fired when speech starts. + * + * # Safety + * `ctx` must be valid. + */ +void tts_set_on_start(struct tts_ctx *ctx, CVoidCb cb, void *userdata); + +/** + * Set the callback fired when speech completes successfully. + * + * # Safety + * `ctx` must be valid. + */ +void tts_set_on_end(struct tts_ctx *ctx, CVoidCb cb, void *userdata); + +/** + * Set the callback fired when speech fails. + * + * The error message is a null-terminated C string valid for the duration + * of the callback only. + * + * # Safety + * `ctx` must be valid. + */ +void tts_set_on_error(struct tts_ctx *ctx, CErrorCb cb, void *userdata); + +/** + * Return the number of registered engines. + */ +int32_t tts_get_engine_count(void); + +/** + * Get the list of available engine descriptors. + * + * On success, writes a heap-allocated array to `*out_engines` and its length + * to `*out_count`. Caller must free with [`tts_free_engines`]. + * + * Returns 0 on success, -1 on failure. + * + * # Safety + * + * `out_engines` and `out_count` must be non-null. + */ +int32_t tts_get_engines(struct tts_engine_info **out_engines, int32_t *out_count); + +/** + * Free an engine info array previously returned by [`tts_get_engines`]. + * + * # Safety + * + * `engines` must be a pointer from `tts_get_engines` with the matching `count`. + */ +void tts_free_engines(struct tts_engine_info *engines, int32_t count); + +/** + * Return the last error message as a C string, or null if none. + * + * If ctx is provided, returns the per-context error. If ctx is null, + * returns the global error (for tts_create failures). + * + * The returned pointer is valid until the next call to any TTS function. + * + * # Safety + * + * `ctx` may be null (returns global error), or a valid context pointer. + */ +const char *tts_get_last_error(struct tts_ctx *ctx); + +/** + * Pause in-progress speech. + * + * # Safety + * `ctx` must be valid. + */ +void tts_pause(struct tts_ctx *ctx); + +/** + * Resume paused speech. + * + * # Safety + * `ctx` must be valid. + */ +void tts_resume(struct tts_ctx *ctx); + +/** + * Synthesize text to audio bytes without playback. + * Writes a heap-allocated buffer to `*out_bytes` and its length to `*out_len`. + * Caller must free with [`tts_free_bytes`]. + * Returns 0 on success, -1 on failure. + * + * # Safety + * `ctx` must be valid. `out_bytes` and `out_len` must be non-null. + */ +int32_t tts_synth_to_bytes(struct tts_ctx *ctx, + const char *text, + uint8_t **out_bytes, + uintptr_t *out_len); + +/** + * Free a byte buffer returned by [`tts_synth_to_bytes`]. + * + * # Safety + * `bytes` must be from `tts_synth_to_bytes` with the matching `len`. + */ +void tts_free_bytes(uint8_t *bytes, uintptr_t len); + +extern void *avsynth_create(void); + +extern void avsynth_destroy(void *handle); + +extern void avsynth_speak(void *handle, + const char *text, + const char *voice_id, + float rate, + float pitch, + float volume); + +extern void avsynth_stop(void *handle); + +extern void avsynth_pause(void *handle); + +extern void avsynth_resume(void *handle); + +extern int32_t avsynth_voice_count(void *handle); + +extern int32_t avsynth_get_voice(void *handle, + int32_t index, + char *id_buf, + int32_t id_buf_len, + char *name_buf, + int32_t name_buf_len, + char *lang_buf, + int32_t lang_buf_len); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif /* TTS_WRAPPER_H */ From 17a2c72991b16ee0262fa5d19cde0920fc323591 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 21 Aug 2026 08:31:58 +0000 Subject: [PATCH 3/7] bindings: exclude tests/ from the lib csproj glob; import Foundation in Package.swift --- .github/workflows/bindings.yml | 2 +- bindings/dotnet/RustTtsWrapper.Bindings.csproj | 8 ++++++++ bindings/swift/Package.swift | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bindings.yml b/.github/workflows/bindings.yml index f8898ae..a5d7ea5 100644 --- a/.github/workflows/bindings.yml +++ b/.github/workflows/bindings.yml @@ -102,7 +102,7 @@ jobs: echo "TTS_WRAPPER_LIB=$LIB" >> "$GITHUB_ENV" - name: dotnet test working-directory: bindings/dotnet - run: dotnet test -p:SkipNativeLibCheck=true + run: dotnet test tests/RustTtsWrapper.Bindings.Tests.csproj -p:SkipNativeLibCheck=true swift: name: Swift (macOS) 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 @@ + + + + + +