voice_audio_multi/docs/c_api.md
Fabio c36510c21e
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
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

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, &registry);
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.