Imported from yvanvds/yse-soundengine (
.claude/skills/c-api-extend/SKILL.md). Install upstream withnpx skills add yvanvds/yse-soundengine --skill c-api-extend. Copyright stays with the author.
Extending the YSE C API for new engine surface
YseEngine/c_api/ is the only entry point for FFI consumers (Dart, Python, …).
The C API has explicit rules — RT-safe callback bridges, opaque-handle
ownership, null-safe no-op semantics, YSE_C_CALLBACK ABI defence,
exception-to-YseStatus translation, enum drift guard — that the engine's
own public C++ API does not enforce. New wrapping must follow all of them; a
careless callback bridge can stall the audio thread.
This skill governs how to wrap new engine surface in the C API. Read it
fully before editing any file under YseEngine/c_api/.
Issue tracking — GitHub Issues first
CLAUDE.md mandates an issue before code. For this skill:
- File the issue first with
gh issue create --title "c-api: ...". Use theenhancementortasktemplate; tag the layer asc-api. - Branch from
devas<issue-number>-c-api-<short-slug>and PR back todev. Notmaster— see CLAUDE.md §1. - Read context with
gh issue view <n>before starting if the request mentions an existing issue number.
The gh CLI is authenticated in the project environment.
When to invoke this skill
Yes:
- A new engine class / method / enum was added and needs C-ABI exposure.
- The user asks to audit the C API for drift from the engine.
- The user is starting a new C-API wrapping pass (e.g. a sub-issue of a C API audit epic such as #898).
No:
- Modifying behaviour of an existing C-API function without adding surface — that's a regular fix, use the normal workflow.
- Internal engine refactors that don't change the public C++ surface.
- Adding audio-thread-reachable callbacks (
dspSourceObjectuser callback,customFileReader) on a casual wrapping pass. Those need design work — see the "Callback bridge rules" block in yse_c_internal.hpp.
The six conventions
Every new C-API entry point must respect all six.
1. Opaque handle shape and ownership
Each engine type that gets a C handle is declared with typedef struct YseFoo YseFoo; and accessed only via YseFoo* pointers. The .cpp file
does reinterpret_cast<YSE::foo*>(handle). Never expose any C++ type,
class, namespace, reference, or template across the C ABI.
Every typedef in its home header carries a one-line ownership comment:
/* Owned — release with yse_foo_destroy. */
typedef struct YseFoo YseFoo;
/* Borrowed singleton — owned by the engine, never destroy.
Obtain via yse_foo_get(). */
typedef struct YseFoo YseFoo;
/* Borrowed — owned by parent YsePatcher. Release with
yse_patcher_delete_object(patcher, handle); never call a destroy on
the handle directly. */
typedef struct YsePHandle YsePHandle;
Dual-shape types (e.g. YseChannel is owned via yse_channel_create but
borrowed via the pre-built accessors) get both lines.
Forward-declaration typedefs (in headers that don't own the type) point the reader at the home header rather than duplicating the ownership note:
/* Forward declarations — see yse_channel.h / yse_dsp.h for ownership. */
typedef struct YseChannel YseChannel;
typedef struct YseDspBuffer YseDspBuffer;
Every handle typedef — home header and forward declaration alike — sits
behind its own YSE_C_HANDLE_<Type> guard (#976), because repeating a
typedef is a C11 feature and the headers must stay C99-clean:
#ifndef YSE_C_HANDLE_YseFoo
#define YSE_C_HANDLE_YseFoo
/** Owned — release with yse_foo_destroy. */
typedef struct YseFoo YseFoo;
#endif
Keep the comment inside the guard, directly above the typedef: placed
above the #ifndef, Doxygen attaches it to the #define instead, and the
Sphinx build fails on the duplicated macro.
The yse_c99_header_check object library in Tests/CMakeLists.txt compiles
every public header as -std=c99 -pedantic-errors, so an unguarded typedef
fails the test build.
Canonical examples: yse_patcher.h (owned + borrowed pair), yse_reverb.h (dual-shape).
2. Header layout
Every include/yse_c/yse_<module>.h:
- Top-of-file block comment naming purpose, the mirrored engine source, scope notes, and the null-safe-no-op convention statement.
#ifndef/#define/#endifinclude guards. No#pragma once.#include "yse_common.h"(andyse_enums.hif needed) — never an engine C++ header.#ifdef __cplusplus / extern "C" { ... } / #endifwraps the body.YSE_C_APIon every exported function declaration.YSE_C_CALLBACKon every callback typedef, namedYse<Module><What>Callbackin PascalCase (YseBusTapCallback,YseScriptErrorCallback,YsePatcherSendCallback) — never a lowercaseyse_*_cb(#911 renamed the last of those).- Functions carry their header's module prefix:
yse_<module>_...(yse_python_run_script, notyse_run_script). - General-purpose helpers live in
yse_common.h(yse_last_error,yse_free_stringfor library-allocated strings), not in whichever module first needed them. - All booleans as
int(0/1), neverbool. All sizes assize_torunsigned int. Strings asconst char*(in) orchar* + size_t(snprintf-style out).
Canonical example: yse_sound.h.
3. Function shape
| Engine signature | C API shape |
|---|---|
void play() — can't fail |
void yse_x_play(YseX* h) — null-safe no-op |
void setFoo(T v) |
void yse_x_set_foo(YseX* h, T v) — null-safe no-op |
T getFoo() const |
T yse_x_get_foo(YseX* h) — return 0 / false / NULL on null |
bool create(...) — can fail |
YseStatus yse_x_load_...(...) — set_last_error on failure |
const char* getName() const |
size_t yse_x_get_name(YseX* h, char* buf, size_t cap) — snprintf style |
std::string getBlob() const (engine-internal) |
Same as above; never return const char* to engine-owned storage that can free |
The header-top convention paragraph captures the void-no-op rule once for the whole header. Per-function comments are reserved for genuinely surprising behaviour.
Body pattern for fallible operations (lift from yse_sound.cpp):
YSE_C_API YseStatus yse_x_load(YseX* h, const char* arg) {
if (!h) return YSE_ERR_INVALID_HANDLE;
if (!arg) return YSE_ERR_INVALID_ARGUMENT;
try {
if (!to_cpp(h)->load(arg)) {
yse_c::set_last_error(std::string("x load failed for: ") + arg);
return YSE_ERR_<specific>;
}
return YSE_OK;
} catch (const std::exception& e) {
yse_c::set_last_error(e.what());
return YSE_ERR_EXCEPTION;
} catch (...) {
yse_c::set_last_error("yse_x_load: unknown C++ exception");
return YSE_ERR_EXCEPTION;
}
}
Body pattern for void state-change:
YSE_C_API void yse_x_play(YseX* h) { if (h) to_cpp(h)->play(); }
Body pattern for string-out:
YSE_C_API size_t yse_x_get_name(YseX* h, char* buf, size_t cap) {
if (!h) { if (buf && cap > 0) buf[0] = '\0'; return 0; }
return copy_string(to_cpp(h)->getName(), buf, cap);
}
Canonical example for the snprintf pattern: yse_device.cpp.
4. Callback bridges — RT-safe shape
When the engine invokes a user-provided callback on a non-host thread
(audio callback, RtMidi input thread, file streaming worker, the
control-thread occlusion driver, future dspSourceObject /
customFileReader paths), the bridge:
- Holds the callback + user_data as one immutable pair node behind a
single
std::atomic<Pair*>— never two separate atomics, which let a dispatch between the two stores of a re-install pair one install's cb with another's user_data (#902, #916). Neverstd::mutexorstd::lock_guardon the dispatch path. - Installs by allocating the new pair (inside the ABI exception barrier)
and publishing it with one atomic
exchange. - Dispatches by loading the pointer once (return if null) and reading cb
and user_data from that same node. The replaced node is freed only after
a named grace period proves no dispatch still reads it: the log bridge
waits for the sink mutex every dispatch runs under; the MIDI raw bridge
uses a seq_cst reader-count handshake around the pointer load, with no
user code inside the window the installer waits on. A straight
passthrough to an engine setter is fine only when that setter keeps the
pair as one node itself (
YSE::midiIn's raw / parsed setters do since #917); still wrap the install in the exception barrier. - Does not
malloc/new/ constructstd::stringon dispatch when the bridge can fire from the audio callback. For raw byte buffers needed by an async-Dart host, preallocate a per-handle pool keyed by the handle. - Uses
YSE_C_CALLBACKon the typedef. - If the bridge transfers heap ownership to the host (Dart's
NativeCallable.listenerrequires this), pair the callback with ayse_<module>_free_messagefunction and document the contract beside the typedef.
Canonical examples:
- yse_midi.cpp —
c_raw_bridge, pair-pointer swap reclaimed by a reader-count handshake; malloc per call is acceptable because the RtMidi input thread is not the audio callback. - yse_log.cpp —
CallbackBridge, same pair-pointer swap reclaimed behind the log sink mutex, same Dart-ownership malloc.
The "Callback bridge rules" block at the head of yse_c_internal.hpp restates these rules in detail. Read it before designing any new bridge.
5. Enum mirroring + drift guard
When the engine adds an enum value that should be visible from the C API:
- Add a matching
Yse<Name>_<VALUE>entry to yse_enums.h. Mirror the integer value exactly. Use C-compatibletypedef enum {...} Yse<Name>;syntax (notenum class). - Add a
YSE_ASSERT_ENUM(YSE_..., YSE::...);line to yse_enums_check.cpp. The build fails loudly if the values drift.
If the engine adds a brand-new enum, add the full mirror block + a full
set of YSE_ASSERT_ENUM lines. The drift guard is the only safety net
for the hand-mirrored file.
6. Build + test integration
- New
.cppfile → append toYSE_C_API_SRCSin c_api/CMakeLists.txt. - New public functions → add at least one smoke test under
Tests/<module>/. The doctest harness picks upTEST_CASEwithout further wiring; see existing tests for patterns. Tests that need a null device useTestHelpers::engineInit(). - Build with the project wrapper, never raw cmake invocations:
python yse.py build && python yse.py test. CTest must stay green across all 44 entries (count them withctest --test-dir build-tests -N; the number grows as suites are isolated). - C API mirror suites run per process. capilowcov, capilowcovlife,
capisurface, close_interleaving, buscapi and the other C-API suites are
registered as their own ctest entries and are not all exercised by
the monolithic
yse_unit_testsbinary. When touching the registry, metadata, protocols or lifecycle, run the relevant entries individually (ctest --test-dir build-tests -R <name>), not just the monolithic binary. - Python-gated surface (
yse_python.h,yse_module) only builds its live half underpython yse.py build --python/python yse.py test --python— run those when touching it.
Workflow
- Inventory the new engine surface. Read the engine header (e.g.
YseEngine/sound/soundInterface.hpp); list every public method, enum, and callback. Skip private helpers and the implementation pimpls. - Map ownership. For each new class, decide: owned (user create/destroy), borrowed singleton, or borrowed by parent.
- Plan the module file. New subsystem → new
yse_<module>.h+yse_<module>.cpppair + CMakeLists entry. Extending an existing subsystem → add to the existing pair. - Apply the six conventions as you write. Lift bodies from the canonical examples — don't re-invent the shape.
- Update the enum mirror + drift guard if any new enum value landed in the engine.
- Update c_api/CMakeLists.txt if new files were added.
- Add a test stub under
Tests/<module>/. At minimum: aTEST_CASEthat exercises create/destroy and one method. - Build + test:
python yse.py build && python yse.py test. Must be green. - PR to
devwith a body that references the closing issue and lists every new public function in the summary.
Hard refusals
Stop and surface a design note rather than emit code that:
- Contains
std::mutexorstd::lock_guardin any callback bridge body. (PR #63 had to remove these fromyse_log.cpp; do not put them back.) The rule is about bridge dispatch paths and anything the audio thread can reach. The one allowed exception today is the live-handle registry in yse_instrument.cpp (registryMutex()): SFZ-instrument / DX7-bank handles are validated against it to turn double-free and use-after-destroy into logged no-ops (#178), and every caller is a control / setup-thread entry point that already allocates and reads files. Nothing on the audio thread or in a callback touches it. A new mutex needs the same argument, written beside it in a comment, or it is refused. - Allocates memory (
malloc,new,std::stringconstruction) in a callback that fires on the audio thread. If the host genuinely needs byte ownership, design a preallocated pool. - Exposes any C++ type, namespace, reference, or template in a public
yse_c/*.hheader. - Catches
std::exceptionand rethrows, swallows, or returns 0/NULL without first callingyse_c::set_last_error. - Adds a void function that throws on null instead of being a null-safe no-op.
- Adds a callback typedef without
YSE_C_CALLBACK. - Adds an enum to
yse_enums.hwithout a matchingYSE_ASSERT_ENUMinyse_enums_check.cpp.
If the engine's design genuinely requires one of these (rare), file an issue describing the conflict and wait for a decision before proceeding.
Things this skill DOES NOT do
- Modify vendored dependencies under
dependencies/orbuild*/_deps/. - Add new public methods, classes, or enums to the engine. Engine changes go through the engine's own workflow first; this skill mirrors them.
- Refactor existing C-API surface unless the engine change requires it.
- Wrap callbacks that fire on the audio thread (
dspSourceObject,customFileReader) — those need additional design work (preallocated pools, stack-only return paths) per yse_c_internal.hpp's rules block. Surface a design proposal first. (Occlusion is no longer deferred: since #209 it runs on the control thread insideSystem().update(), and #906 bridged it asyse_system_set_occlusion_callbackinyse_system.cpp.) - Modify CLAUDE.md or PROJECT_OVERVIEW.md unless the wrapping pass introduces a structural change worth documenting at the project root.
Verification
Before reporting the wrapping task complete:
python yse.py buildsucceeds with no new warnings.python yse.py testpasses all 44 CTest entries (the project's full suite, not just the new test), plus the per-process C-API entries you touched run individually.grep "std::mutex\|std::lock_guard" YseEngine/c_api/returns nothing beyond the documentedyse_instrument.cpphandle-registry exception (see Hard refusals).- Every new public function in
include/yse_c/*.his either covered by the header-top void-no-op convention or carries its own explanatory comment. - Every new opaque typedef has the ownership one-liner.
- Every new callback typedef has
YSE_C_CALLBACK. - Every new enum value has a matching
YSE_ASSERT_ENUMline.
If any of these fail, the wrapping is incomplete — keep iterating rather than reporting done.
