scsynth-sys 0.1.0

Raw FFI bindings to a statically-linked SuperCollider scsynth engine.
// C++-side accessors that `bindgen` cannot reproduce. Compiled on both targets.
//
// `WorldOptions` has a user-declared constructor and C++11 default member initialisers
// (`SC_WorldOptions.h`); `bindgen` lays out the fields but cannot reproduce those defaults. The
// `scsynth_default_world_options` shim hands Rust a correctly-defaulted value, which it can then
// selectively override. `ReplyAddress` is opaque to Rust (it embeds a `boost::asio` address on
// native), so `scsynth_reply_context` reads back the `mReplyData` context the safe wrapper handed
// to `World_SendPacketWithContext` / `scsynth_wasm_perform`, letting reply functions recover their
// sink without Rust ever naming the struct's fields. `scsynth_copy_buffer` wraps `World_CopySndBuf`
// so the engine-owned aligned sample buffer never crosses into Rust (see below).
#include "SC_WorldOptions.h"
#include "SC_ReplyImpl.hpp"
#include "SC_SndBuf.h"
#include "SC_Errors.h"
#include "malloc_aligned.hpp"

#include <array>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <mutex>

extern "C" void scsynth_default_world_options(WorldOptions* out) { *out = WorldOptions(); }

extern "C" void* scsynth_reply_context(ReplyAddress* a) { return a ? a->mReplyData : nullptr; }

// Read back the contents of soundbuffer `index` via `World_CopySndBuf`, handing the samples to `fn`
// (with caller context `ctx`) while the engine-owned copy is still alive, then freeing it.
//
// `World_CopySndBuf` snapshots the buffer under the engine's NRT lock into our scratch `SndBuf`,
// allocating `data` with scsynth's aligned allocator (`nova::malloc_aligned`). That allocator is not
// Rust's (and on wasm the C and Rust heaps are entirely separate), so the buffer must not cross the
// FFI boundary for Rust to free: instead the safe wrapper's `fn` copies the samples out here and we
// `nova::free_aligned` the scratch before returning. Mirrors the `ReplyFunc` + context idiom used
// for OSC replies. Returns the engine's `SCErr` (`kSCErr_None` = 0 on success;
// `kSCErr_IndexOutOfRange` for a bad index). `fn` is not called when the copy fails.
typedef void (*ScBufferFunc)(void* ctx, const float* data, int num_samples, int num_channels,
                             int num_frames);

extern "C" int scsynth_copy_buffer(World* world, uint32 index, ScBufferFunc fn, void* ctx) {
    SndBuf scratch;
    // Zeroed `data`/`samples` make `World_CopySndBuf` allocate a right-sized buffer for us.
    std::memset(&scratch, 0, sizeof(scratch));
    int err = World_CopySndBuf(world, index, &scratch, false, nullptr);
    if (err == kSCErr_None && fn != nullptr) {
        fn(ctx, scratch.data, scratch.samples, scratch.channels, scratch.frames);
    }
    // Safe even when the copy failed or the buffer was empty: `data` is then null and
    // `nova::free_aligned(nullptr)` is a no-op.
    nova::free_aligned(scratch.data);
    return err;
}

// Log capture: redirect scsynth's diagnostic output (`scprintf` -> the global `gPrint`) to a host
// sink. `gPrint` is process-global and carries no per-call context (unlike the reply path's
// `World_SendPacketWithContext`), so capture is necessarily process-wide rather than per-`World`.
//
// `scprintf` is called from both the RT (audio) and NRT threads (e.g. the `Poll` UGen, command
// failures, allocation warnings); the trampoline formats into a fixed scratchpad and forwards the
// bytes to `fn` under a mutex - the same RT/NRT serialisation SuperCollider's own webaudio print
// buffer uses (and no less real-time-safe than the default, which does `vprintf` straight to stdio).
typedef void (*ScLogFunc)(void* ctx, const char* text, int len);

namespace {
ScLogFunc g_log_func = nullptr;
void* g_log_ctx = nullptr;
std::mutex g_log_mutex;

int scsynth_log_trampoline(const char* fmt, va_list vargs) {
    std::array<char, 4096> buf;
    int n = std::vsnprintf(buf.data(), buf.size(), fmt, vargs);
    if (n < 0) {
        return n;
    }
    // vsnprintf returns the length it WOULD have written; clamp to what fits on truncation.
    int len = n < static_cast<int>(buf.size()) ? n : static_cast<int>(buf.size()) - 1;
    std::lock_guard<std::mutex> lock(g_log_mutex);
    if (g_log_func != nullptr) {
        g_log_func(g_log_ctx, buf.data(), len);
    }
    return n;
}
} // namespace

// Install (`fn != null`) or clear (`fn == null`, restoring the engine's default stdout `vprintf`) the
// global log sink. The `fn`/`ctx` pair and the `SetPrintFunc` call are updated together under the
// trampoline's mutex so a concurrent `scprintf` never observes a half-updated pair.
extern "C" void scsynth_set_log_func(ScLogFunc fn, void* ctx) {
    std::lock_guard<std::mutex> lock(g_log_mutex);
    g_log_func = fn;
    g_log_ctx = ctx;
    SetPrintFunc(fn != nullptr ? scsynth_log_trampoline : nullptr);
}