Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
339 lines
15 KiB
Markdown
339 lines
15 KiB
Markdown
# C API
|
|
|
|
`include/audiocpp.h` is a C ABI for embedding audio.cpp **in-process**. It is a
|
|
facade over `engine::runtime` and adds no behaviour of its own: every call maps
|
|
onto `ModelRegistry` → `ILoadedVoiceModel` → `IVoiceTaskSession`, the same
|
|
surfaces `audiocpp_cli` uses.
|
|
|
|
It is off by default in source builds. Releases provide separate
|
|
`audio-<tag>-lib-<platform>-<backend>` archives, alongside the existing binary
|
|
archives. Each library archive contains `include/audiocpp.h`, `libs/`, and the
|
|
license. Windows archives include `audiocpp.lib` for linking. Choose the archive
|
|
for your platform and backend: CPU, CUDA, Vulkan, or Metal. Keep bundled shared
|
|
dependencies beside the library in `libs/`; GPU drivers and the Vulkan loader
|
|
must be installed on the host. The server-only Colab bundle is unchanged.
|
|
Dynamic backend plugins are discovered beside the C API library on Linux,
|
|
macOS, and Windows, independently of the application's working directory.
|
|
|
|
CUDA libraries for Linux (12.8 and 13.3) and Windows use a separate matching
|
|
`audio-<tag>-cudart-<platform>-cuda<version>` archive. Extract its contents into
|
|
the library package's `libs/` directory, or make that runtime directory available
|
|
to the system loader. The NVIDIA driver is not bundled.
|
|
Windows Vulkan also has a separate baseline `vulkan-portable` package for CPUs
|
|
without AVX/AVX2; the existing Vulkan package is unchanged.
|
|
|
|
```bash
|
|
cmake -S . -B build -DAUDIOCPP_BUILD_C_API=ON
|
|
cmake --build build --target audiocpp
|
|
```
|
|
|
|
This produces `libaudiocpp.so` / `libaudiocpp.dylib` / `audiocpp.dll` with
|
|
`SOVERSION 0`. Nothing changes in a build that leaves the option off.
|
|
|
|
## When to use it
|
|
|
|
| | best for | cost |
|
|
|---|---|---|
|
|
| `audiocpp_cli` | scripting, batch jobs | a process per request; weights reload every time |
|
|
| `audiocpp_server` | multi-client, remote, OpenAI-compatible clients | a port, a supervised process, audio serialization per call |
|
|
| C API | embedding in an application | you manage the handles yourself |
|
|
|
|
The C API is the only one of the three that keeps a session warm inside your own
|
|
process, so it is the one that benefits from graph and cache reuse across
|
|
requests.
|
|
|
|
## Contract
|
|
|
|
- **Opaque handles only.** No C++ type crosses the boundary.
|
|
- **No exception escapes.** Every entry point returns `audiocpp_status`.
|
|
`audiocpp_last_error()` gives the detail for the last failing call *on the
|
|
calling thread*.
|
|
- **Returned `const char *` and `const float *` are borrowed.** They are valid
|
|
until the handle that produced them is freed or mutated. Copy anything you
|
|
intend to keep.
|
|
- **Strings are never NULL.** A field the model did not populate reads as `""`,
|
|
so callers need no null checks on returned strings.
|
|
- **Freeing NULL is a no-op**, so cleanup paths need no null checks either.
|
|
- **Out parameters are optional.** Pass NULL for anything you do not want.
|
|
- **Handles are not individually thread-safe.** Separate handles may be used
|
|
concurrently from separate threads.
|
|
|
|
### Lifetime
|
|
|
|
Handles hold their parents alive internally: a session holds its model, and a
|
|
model holds its registry. **Freeing out of order is therefore safe.**
|
|
|
|
```c
|
|
audiocpp_model_free(model); /* the session keeps working */
|
|
audiocpp_registry_free(registry);
|
|
audiocpp_session_run(session, request, &result); /* still valid */
|
|
audiocpp_session_free(session);
|
|
```
|
|
|
|
This is deliberate. Callers from garbage-collected runtimes cannot control the
|
|
order finalizers run in, and requiring them to would make the ABI a trap. The
|
|
path test asserts it.
|
|
|
|
### Versioning
|
|
|
|
`audiocpp_abi_version()` returns `(major << 16) | (minor << 8) | patch`. A
|
|
caller built against a different **major** must not use the library. Check it
|
|
once at load time:
|
|
|
|
```c
|
|
if ((audiocpp_abi_version() >> 16) != AUDIOCPP_ABI_VERSION_MAJOR) {
|
|
/* refuse to proceed */
|
|
}
|
|
```
|
|
|
|
**minor** increments when entry points are added. Nothing is removed or changed
|
|
by such a release, so a caller built against a lower minor keeps working
|
|
untouched — but a caller that needs a newer entry point can say so, which is
|
|
the only reason the field carries information:
|
|
|
|
```c
|
|
/* audiocpp_request_set_option_array arrived in 0.2. */
|
|
if ((audiocpp_abi_version() & 0xffff) < 0x0200) {
|
|
/* fall back, or refuse, rather than resolving a symbol that is not there */
|
|
}
|
|
```
|
|
|
|
A C caller can also just resolve the symbol and test for NULL, which is exact
|
|
and needs no number at all. The version is for the callers that cannot: a
|
|
binding that declares its imports up front — C#, JNA, ctypes with prototypes —
|
|
binds on first use and raises a missing-symbol error from inside the call, which
|
|
is a poor way to discover that a library is too old.
|
|
|
|
**patch** is for behaviour fixes that add and change no surface. Do not gate on
|
|
it; it tells a caller nothing about what it may call.
|
|
|
|
⚠ `0.1` covers two different surfaces. The four entry points added in #544
|
|
(`audiocpp_task_count`, `audiocpp_task_name`, `audiocpp_task_from_spec_name`,
|
|
`audiocpp_request_set_text_language`) shipped without a bump, before this rule
|
|
existed, so a library reporting `0.1` may or may not have them. From `0.2`
|
|
onward the minor is the answer.
|
|
|
|
## Usage
|
|
|
|
```c
|
|
#include "audiocpp.h"
|
|
|
|
audiocpp_registry * registry = NULL;
|
|
audiocpp_model * model = NULL;
|
|
audiocpp_session * session = NULL;
|
|
audiocpp_request * request = NULL;
|
|
audiocpp_result * result = NULL;
|
|
|
|
audiocpp_model_config config = { "kokoro_tts", NULL, NULL, NULL };
|
|
|
|
audiocpp_registry_create(NULL, ®istry);
|
|
audiocpp_model_load(registry, "models/Kokoro-82M-GGUF/kokoro-82m-q8_0.gguf",
|
|
&config, NULL, &model);
|
|
|
|
audiocpp_backend_config backend = { "cuda", 0, 4 };
|
|
audiocpp_session_create(model, "tts", "offline", &backend, NULL, &session);
|
|
|
|
request = audiocpp_request_create();
|
|
audiocpp_request_set_text(request, "Hello from audio.cpp.", "en-us");
|
|
audiocpp_request_set_option(request, "voice-id", "af_heart");
|
|
|
|
if (audiocpp_session_run(session, request, &result) == AUDIOCPP_OK) {
|
|
const float * samples; size_t frames; int rate, channels;
|
|
audiocpp_result_audio(result, &samples, &frames, &rate, &channels);
|
|
/* samples is interleaved and borrowed: frames * channels floats. */
|
|
}
|
|
|
|
audiocpp_result_free(result);
|
|
audiocpp_request_free(request);
|
|
audiocpp_session_free(session);
|
|
audiocpp_model_free(model);
|
|
audiocpp_registry_free(registry);
|
|
```
|
|
|
|
`task` and `mode` take the same spellings as `--task` and `--mode`; `backend`
|
|
takes the same spellings as `--backend`. Options map 1:1 onto `--load-option`,
|
|
`--session-option`, and `--request-option`, and the fields of
|
|
`audiocpp_model_config` map onto `--family`, `--config`, `--weight` and
|
|
`--model-spec-override`.
|
|
|
|
Beyond text and audio, a request carries everything `TaskRequest` does: a
|
|
speaker reference (`audiocpp_request_set_voice_id` /
|
|
`..._set_voice_audio`), style conditioning (`..._set_speaking_rate`,
|
|
`..._set_pitch_shift`, `..._set_emotion`, `..._set_style_language`,
|
|
`..._set_energy_scale`, `..._set_style_tag`), and input artifacts
|
|
(`audiocpp_request_add_artifact`). Results expose the matching outputs,
|
|
including artifacts, so a speaker embedding can be read out of one result and
|
|
fed back into a later request instead of being re-derived. The server's generic
|
|
task routes and the CLI request JSON carry artifacts the same way, as an
|
|
`artifacts` array (see the [server docs](../app/server/README.md#post-v1tasksrun)).
|
|
|
|
## Discovering what a model accepts
|
|
|
|
`ModelInspection::cli` is exposed, so a binding can enumerate a family's options
|
|
at runtime instead of hardcoding per-family knowledge:
|
|
|
|
```c
|
|
size_t count = audiocpp_model_option_count(model, AUDIOCPP_OPTION_SCOPE_REQUEST);
|
|
for (size_t i = 0; i < count; ++i) {
|
|
const char *name, *value_name, *description, *fallback, *min_value, *max_value;
|
|
int required;
|
|
audiocpp_model_option(model, AUDIOCPP_OPTION_SCOPE_REQUEST, i,
|
|
&name, &value_name, &description, &fallback,
|
|
&min_value, &max_value, &required);
|
|
}
|
|
```
|
|
|
|
Bounds are read-only discovery metadata; returning `min_value` and `max_value`
|
|
does not enforce them on requests. They can guide controls and input hints, but
|
|
are not a complete validation contract. Read `description` for excluded
|
|
endpoints and conditional constraints, such as a temperature that must be
|
|
`> 0` only when sampling is enabled. Model parsers and consumers enforce actual
|
|
values. See [option metadata](maintainers/model_specs.md#metadata-vs-runtime-loading).
|
|
|
|
This is what keeps the ABI independent of the model surface: the option maps are
|
|
`string -> string` in the framework already, so **adding a model family never
|
|
changes this header**.
|
|
|
|
## Streaming
|
|
|
|
Pull-based, mirroring `IStreamingVoiceTaskSession`. No callback crosses the FFI
|
|
boundary.
|
|
|
|
```c
|
|
audiocpp_session_create(model, "vad", "streaming", &backend, NULL, &session);
|
|
|
|
int64_t chunk = 0;
|
|
audiocpp_stream_policy(session, NULL, NULL, &chunk, NULL); /* frames per push */
|
|
audiocpp_stream_start(session, NULL);
|
|
|
|
for (...) {
|
|
audiocpp_event * event = NULL;
|
|
audiocpp_stream_push(session, block, (size_t)chunk, 16000, 1, offset, &event);
|
|
if (event) {
|
|
const audiocpp_result * view = audiocpp_event_as_result(event);
|
|
/* read via the result accessors */
|
|
audiocpp_event_free(event);
|
|
}
|
|
}
|
|
|
|
audiocpp_stream_finish(session, &result);
|
|
```
|
|
|
|
A `StreamEvent` carries the same shapes a `TaskResult` does under different
|
|
names, so it is read through the result accessors: `partial_text` appears as
|
|
the result's text, and any voice-activity event carrying a segment appears as a
|
|
speech segment. `audiocpp_stream_next_event()` sets `*out_event` to NULL when
|
|
the queue is empty — that is `AUDIOCPP_OK`, not an error.
|
|
|
|
## Symbol surface
|
|
|
|
`libaudiocpp.so` exports exactly the 55 entry points in `audiocpp.h` and nothing
|
|
else. This needs a linker export list, not just `CXX_VISIBILITY_PRESET hidden`:
|
|
the visibility preset reaches only this target's own sources, while the static
|
|
libraries linked in (ggml, cJSON, sentencepiece, ...) are compiled without
|
|
`-fvisibility=hidden` and would otherwise be re-exported — 2767 dynamic symbols
|
|
instead of 55, any of which could collide with something the host application
|
|
has already loaded.
|
|
|
|
`src/capi/audiocpp.map` (ELF) and `src/capi/audiocpp.symbols` (Mach-O) hold that
|
|
list.
|
|
|
|
Windows works differently and is worth understanding, because the obvious
|
|
assumption is wrong. `__declspec(dllexport)` is **additive** — it says "also
|
|
export this", never "export only this" — so a DLL's surface is its own
|
|
annotations plus every annotation in the static libraries it absorbs. There is
|
|
no PE equivalent of an allowlist short of a `.def` file. Vendored cJSON defaults
|
|
to annotating all 78 of its public functions, which put the first Windows build
|
|
at 147 exports instead of 69; `cjson_vendor` is therefore compiled with
|
|
`CJSON_HIDE_SYMBOLS`. If a future dependency starts annotating its symbols, the
|
|
Windows surface will grow again and neither of the two allowlists will stop it —
|
|
which is why `audiocpp_c_api_exports` asserts the surface on every platform
|
|
rather than leaving it to this paragraph.
|
|
|
|
To check:
|
|
|
|
```bash
|
|
# ELF
|
|
nm -D --defined-only build/bin/libaudiocpp.so | awk '$2=="T"{print $3}' | grep -cv '^audiocpp_'
|
|
# Mach-O
|
|
nm -gU build/bin/libaudiocpp.dylib | awk '{print $NF}' | grep -cv '^_audiocpp_'
|
|
# PE
|
|
dumpbin /EXPORTS build\bin\Release\audiocpp.dll
|
|
# all three: 0 non-matching, 69 total
|
|
```
|
|
|
|
## Notes
|
|
|
|
- The library does **not** call `omp_set_num_threads()`. `audiocpp_cli` does,
|
|
but that is process-global state and a library embedded in a host application
|
|
has no business changing it. Set `threads` in `audiocpp_backend_config`.
|
|
- `audiocpp_session_run()` prepares the session implicitly. Call
|
|
`audiocpp_session_prepare()` directly only to pay that cost ahead of a
|
|
latency-sensitive run; the next run will then skip it.
|
|
- The facade depends only on framework headers, never on `app/`, so the CLI and
|
|
the C API stay independent of each other. The one consequence is that the
|
|
backend-name mapping is duplicated from `app/cli/args.cpp`.
|
|
|
|
## Test
|
|
|
|
`tests/capi/path_test.c` is compiled **as C**, which is the point: the header
|
|
must be consumable by a C compiler with no C++ runtime on the caller's side. It
|
|
runs against Silero VAD, which ships in-repo, so it needs no model download.
|
|
|
|
```bash
|
|
cmake -S . -B build -DAUDIOCPP_BUILD_C_API=ON
|
|
cmake --build build --target audiocpp_c_api_path_test
|
|
ctest --test-dir build -R audiocpp_c_api_path --output-on-failure
|
|
```
|
|
|
|
Note the `-R audiocpp_c_api_path` there rather than the wider `-R audiocpp_c_api`.
|
|
CTest treats a registered test whose executable has not been built as a hard
|
|
failure rather than a skip, so running the full selector after building only one
|
|
test binary reports red for a reason that is not a real failure. Either name the
|
|
binaries you want, as below, or just `cmake --build build` and let everything
|
|
build.
|
|
|
|
The registered test runs on CPU so it needs no GPU. A fourth argument selects
|
|
another backend through the ABI:
|
|
|
|
```bash
|
|
./build/bin/audiocpp_c_api_path_test \
|
|
assets/framework/models/silero_vad assets/resources/sample_16k.wav cuda
|
|
```
|
|
|
|
### Tests
|
|
|
|
| test | needs | covers |
|
|
|---|---|---|
|
|
| `audiocpp_c_api_exports` | nothing | that the library exports exactly this header |
|
|
| `audiocpp_c_api_path` | nothing (in-repo model) | the ABI contract, offline + streaming |
|
|
| `audiocpp_c_api_model` | downloaded models | real families across TTS / ASR / diarization |
|
|
| `audiocpp_c_api_parity` | models + `audiocpp_cli` | that the C API and the CLI agree |
|
|
|
|
The last two **skip** (CTest exit 77) rather than fail when models are absent, so
|
|
a checkout with no downloads still reports green. Point them at a models root:
|
|
|
|
```bash
|
|
cmake -S . -B build -DAUDIOCPP_BUILD_C_API=ON \
|
|
-DAUDIOCPP_C_API_MODEL_ROOT=/path/to/models
|
|
cmake --build build --target audiocpp_c_api_path_test audiocpp_c_api_model_test audiocpp_cli
|
|
ctest --test-dir build -R audiocpp_c_api --output-on-failure
|
|
```
|
|
|
|
`audiocpp_c_api_parity` is the one that answers "is this actually working": the
|
|
C API drives the same runtime the CLI does, so for identical inputs the two must
|
|
produce identical output.
|
|
|
|
Note that generative families draw noise per run — two unseeded `audiocpp_cli`
|
|
Kokoro runs over the same text produce different audio — so the parity check
|
|
seeds both sides. Without that the comparison would be meaningless.
|
|
|
|
### The path test
|
|
|
|
It covers both the offline and the streaming surface -- Silero advertises
|
|
`vad/streaming`, so no extra model is needed -- and asserts the ABI contract
|
|
rather than the model's judgement: status codes, borrowed-string rules,
|
|
out-of-range handling, out-of-order frees, each mode refusing the other's entry
|
|
point, and that a reused session gives the same answer twice. It deliberately does not assert that
|
|
synthetic audio is detected as speech, which would pin the test to Silero's
|
|
thresholds instead of to the ABI.
|