# scsynth-sys
Raw FFI bindings to a statically-linked SuperCollider [`scsynth`][scsynth] engine.
This crate builds `libscsynth` from a pinned SuperCollider revision, compiles a small C/C++ shim, and
generates Rust bindings over the engine's `extern "C"` surface with `bindgen`. It is the only place
`unsafe` FFI lives; safe abstractions belong in the [`scsynth`](../scsynth) crate.
Two completely different builds sit behind one binding surface, selected by target in
[`build.rs`](build.rs):
- **native** - CMake builds a static `libscsynth` against the system C/C++ toolchain, `libsndfile`
and `fftw`, with our host-pumped *external* audio driver.
- **wasm** (`wasm32-unknown-unknown`) - no system toolchain or sndfile/fftw exist, so the engine's
DSP core + static UGen plugins are compiled **from source** against a from-source musl + libc++
(see [`wasm-toolchain/`](../../wasm-toolchain)), driverless (`mRealTime=false`).
## Where the source comes from
SuperCollider is **not vendored in-tree**. [`source.toml`](source.toml) pins its `{ url, rev }`, and
[`build.rs`](build.rs) resolves the source as: `SCSYNTH_SYS_SUPERCOLLIDER_DIR` if set (the Nix build
and dev shell set it - they never fetch), otherwise a `git` fetch of that exact revision into a
rev-keyed cache (`~/.cache/scsynth-rs/`, overridable with `SCSYNTH_SRC_CACHE_DIR`). The Nix flake
reads the *same* `source.toml`, so the two cannot drift.
We fetch at build time rather than vendoring because SuperCollider's source (~135 MB) is far larger
than crates.io's package-size limit - vendoring it would make this crate unpublishable. The cost: a
first build needs `git` + network (or the env-var override; offline and docs.rs builds must use it).
## How SuperCollider is modified
The fetched source is **never touched**: the build copies it into `OUT_DIR` and applies every change
to the copy. Updating SuperCollider is therefore just bumping the `rev` in `source.toml` - the
patches re-apply automatically (or fail loudly; see below). The changes are of two kinds: files we
*add*, and small *text patches* to SC's own sources.
### Files we add (`csrc/`)
| `SC_ExternalDriver.cpp` | The native **custom backend**: an `SC_AUDIO_API_EXTERNAL` audio driver that owns no device and no audio thread. The host (a `cpal` callback, a test, ...) pumps it one buffer at a time via `scsynth_pump`. Modelled on SuperCollider's externally-driven `SC_WebAudio.cpp`. |
| `SC_WasmPump.cpp` | The wasm driverless shim (`scsynth_wasm_new`/`perform`/`pump`), plus the "fake implementations" that fill the link holes left by the SC files excluded on wasm - mirroring SC's own `SC_WebAudio.cpp`. |
| `shim.cpp` | `scsynth_default_world_options`: constructs a default `WorldOptions` C++-side (bindgen can't reproduce its C++ default member initializers). |
| `libc_gap.c` | The few libc symbols the minimal wasm libc doesn't provide. |
### Text patches to SC sources
Applied to the `OUT_DIR` copy via assert-guarded find-and-replace:
- **Native** (`patch_audioapi_external`): patches `server/scsynth/CMakeLists.txt` to (1) accept
`external` in the `AUDIOAPI` validation regex and (2) add one `elseif(AUDIOAPI STREQUAL external)`
branch that compiles `SC_ExternalDriver.cpp` and defines `SC_AUDIO_API_EXTERNAL`. That is the
entire native tweak - one CMake branch.
- **Wasm** (`patch_sc_sources`): a handful of `#ifndef SC_WASM` guards so the engine links without
the filesystem / networking / shared-memory code that doesn't exist on bare wasm:
- `SC_Endian.h` - add a wasm branch (little-endian, no `<netinet/in.h>`);
- `SC_ReplyImpl.hpp` - drop the `boost::asio` address field;
- `SC_World.cpp` - guard `<filesystem>` and the never-called `World_LoadGraphDefs`;
- `SC_GraphDef.cpp` - guard the `<filesystem>` `/d_load` file-loaders.
Each guard extends a conditional SuperCollider *already has* for `__EMSCRIPTEN__`; we just fire it
on our `SC_WASM` define as well.
### Build steps (per target, in `build.rs`)
1. `copy_tree` - copy the fetched source into `OUT_DIR` (skipping `.git`).
2. `sync_file` - drop `SC_ExternalDriver.cpp` into the copy (native).
3. `patch_audioapi_external` / `patch_sc_sources` - the text patches.
4. `generate_version_header` - produce `SC_Version.hpp` (wasm only; CMake does this natively).
5. Build from the copy - CMake (native) or the `cc` crate (wasm).
### Why this survives SuperCollider updates
Every replacement first `assert!`s that its anchor text is present. If an upstream change moves or
renames an anchor, the build **fails immediately with a precise message** (e.g. "...SuperCollider
CMake layout changed", "...guard anchor not found in <file>") instead of silently mis-building. A
routine SC bump just works; a breaking one points you at the exact patch to update.
## Plugins (UGens)
Both targets build with `STATIC_PLUGINS`, so the available UGens are exactly those registered by
SuperCollider's static-plugin load list (the `#ifdef STATIC_PLUGINS` block in `SC_Lib_Cintf.cpp`):
the core DSP set - `IOUGens`, `OscUGens`, `DelayUGens`, `BinaryOp`/`UnaryOp`/`MulAdd`, `FilterUGens`,
`GendynUGens`, `LFUGens`, `NoiseUGens`/`DynNoiseUGens`, `GrainUGens`, `PanUGens`, `ReverbUGens`,
`TriggerUGens`, `PhysicalModelingUGens`, `TestUGens`, `DemandUGens`, and the FFT/PV chain
(`FFT_UGens` + `PV_UGens` + `PartitionedConvolution`).
The wasm build compiles and registers this **same set**; the only intentional difference is `DiskIO`
(`DiskIn`/`DiskOut`/`VDiskIn`), which native registers through libsndfile but wasm omits (no
filesystem/libsndfile on bare wasm). So web and native expose an identical UGen set apart from disk
I/O.
This is SuperCollider's *static* set, which is narrower than a full desktop install's *dynamically
loaded* plugins: UGens that SuperCollider ships only as separately-loaded plugins - `ChaosUGens`, the
MIR/analysis set (`MFCC`, `BeatTrack`, `KeyTrack`, `Loudness`, `Onsets`, ...), the PV third-party
onset detectors, `UnpackFFTUGens` and `DemoUGens` - are compiled by the native CMake build but
**not** registered by the static load list, so they are unavailable on *both* targets (a SynthDef
that references one fails to load with `UGen '<name>' not installed`). Exposing them would mean
extending that load list (a patch applied to both targets) and is intentionally out of scope.
## Pinning
The SuperCollider revision lives in [`source.toml`](source.toml) - the single source of truth,
shared with the Nix build (which reads the same file). Bump `rev` there to update; the build-time
patches re-apply automatically.
[scsynth]: https://github.com/supercollider/supercollider