# 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--lib--` 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--cudart--cuda` 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.