15 KiB
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.
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 *andconst 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.
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:
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:
/* 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
#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).
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:
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.
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.
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:
# 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_clidoes, but that is process-global state and a library embedded in a host application has no business changing it. Setthreadsinaudiocpp_backend_config. audiocpp_session_run()prepares the session implicitly. Callaudiocpp_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 fromapp/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.
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:
./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:
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.