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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .github/workflows/bindings.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Bindings

# Full test matrix for the language bindings over the C ABI:
# Rust conformance + C harness + Node + .NET + Swift, on the OSes each
# toolchain supports. Mirrors bindings/README.md ("Running the suites").
on:
push:
branches: [main]
pull_request:
branches: [main]

env:
CARGO_TERM_COLOR: always

jobs:
rust-conformance:
name: Rust ABI conformance (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
features: system,cloud
- os: macos-latest
features: avsynth,cloud
- os: windows-latest
features: sapi,cloud
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install native deps (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libspeechd-dev libclang-dev
- name: ABI conformance suites
run: cargo test --no-default-features --features ${{ matrix.features }} --test ffi_conformance --test ffi_lifecycle --test ffi_safety

c-harness:
name: C harness (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install native deps (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libspeechd-dev libclang-dev
- name: Build + compile header with -Wall -Wextra -Werror + run
run: make -C bindings/c test FEATURES=${{ runner.os == 'macOS' && 'avsynth,cloud' || 'system,cloud' }}

node:
name: Node (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install native deps (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libspeechd-dev libclang-dev
- name: Build library
shell: bash
run: cargo build --no-default-features --features ${{ runner.os == 'macOS' && 'avsynth,cloud' || runner.os == 'Windows' && 'sapi,cloud' || 'system,cloud' }}
- name: npm install + test
working-directory: bindings/nodejs
run: |
npm install --no-fund --no-audit
npm test

dotnet:
name: .NET (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"
- name: Install native deps (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libspeechd-dev libclang-dev
- name: Build library (release)
shell: bash
run: cargo build --release --no-default-features --features ${{ runner.os == 'Windows' && 'sapi,cloud' || 'system,cloud' }}
- name: Set TTS_WRAPPER_LIB
shell: bash
run: |
if [ "${{ runner.os }}" = Windows ]; then
LIB=$(cygpath -w "$PWD/target/release/rust_tts_wrapper.dll")
else
LIB="$PWD/target/release/librust_tts_wrapper.${{ runner.os == 'macOS' && 'dylib' || 'so' }}"
fi
echo "TTS_WRAPPER_LIB=$LIB" >> "$GITHUB_ENV"
- name: dotnet test
working-directory: bindings/dotnet
run: dotnet test tests/RustTtsWrapper.Bindings.Tests.csproj -p:SkipNativeLibCheck=true

swift:
name: Swift (macOS)
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Build library (debug)
run: cargo build --no-default-features --features avsynth,cloud
- name: Verify vendored header matches include/tts_wrapper.h
run: diff include/tts_wrapper.h bindings/swift/Sources/CRustTtsWrapper/include/tts_wrapper.h
- name: swift test
working-directory: bindings/swift
env:
TTS_WRAPPER_LIB_DIR: ${{ github.workspace }}/target/debug
run: swift test
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ Cargo.lock
.env
.env.*
!.env.example

# Language-binding build artifacts
node_modules/
**/bin/
**/obj/
51 changes: 38 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,43 +273,68 @@ 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
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

```
Expand Down
110 changes: 110 additions & 0 deletions bindings/README.md
Original file line number Diff line number Diff line change
@@ -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`).
43 changes: 43 additions & 0 deletions bindings/c/Makefile
Original file line number Diff line number Diff line change
@@ -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)
Binary file added bindings/c/tts_abi_harness
Binary file not shown.
Loading
Loading