Custom render backends
Compute a per-speaker gain however you like. Prototype it in Lua and edit it live, or compile a native Rust backend — either way it shows up in the UI on its own.
A render backend is a gain model: give it an object position (plus the live render parameters) and it hands back a gain for each speaker. Got a panning idea of your own — VBAP, DBAP, or something you made up at 2 a.m.? This is where it plugs in — and either way it’s about one file, with no central enum, match, serde bridge, or Studio JavaScript to touch, and a misbehaving backend caught at build time so it can never crash the audio thread.
There are two routes in — prototype in Lua and edit it live with no build step, or compile a native Rust backend for full speed. Pick one:
The no-build route. Pick Script as the render backend and point it at a .lua file — or just hit Edit and write one in Studio’s built-in editor. No toolchain, no recompile: save, and the engine rebuilds with your new law. It runs sandboxed Lua 5.4.
The contract
Your script defines one function:
function gains(pos, speakers, state, params)
-- return one gain per speaker, in speaker order
end
pos— the object position as real cartesian{ x, y, z }(actual distance; axes arexright,yfront,zup).speakers— an array of unit-length directions{ {x,y,z}, … }, one per spatializable speaker.state— whatever your optionalsetup()returned (ornil).params— your tunables as{ key = number, … }.
Return an array of exactly #speakers finite numbers. The host doesn’t normalize for you — do that yourself (there’s a helper). Two optional functions round it out: setup(speakers, params) runs once and hands its return value to every gains call (precompute your triangulation there), and params() declares sliders (below).
The smallest useful backend just delegates to the built-in VBAP:
function gains(pos, speakers)
return normalize_energy(vbap(pos))
end
Helpers you get for free
The sandbox injects a small engine API so you don’t reinvent the hard parts:
vbap(pos [, spread])— the built-in VBAP gains for the whole layout (spread0–1). Alsovbap_new(list)to pan over a chosen subset of speakers.normalize_energy(out)— scale a gain array to constant power.polar(p)/cartesian(s)/normalize(p)/distance(p)— coordinate helpers (angles in degrees).room_scale(p)— warp a point by the room ratios, into the space the built-ins pan in.
vbapalready applies the room warp internally; the coordinate helpers don’t. If you hand-roll a law and want it to match the built-ins in a non-cubic room, run your position throughroom_scale(pos)first.
Only math, table, and string are available — io, os, require, and debug are gone. Scripts run under a 64 MiB memory cap and an instruction budget, so an accidental infinite loop fails the build instead of wedging the engine.
Parameters become sliders
Declare tunables and Studio renders them automatically — same as a native backend’s schema:
function params()
return {
{ key = "sharpness", label = "Sharpness", min = 0.5, max = 8.0,
step = 0.1, default = 2.0, help = "Higher = tighter." },
}
end
Their live values arrive in the params table on every gains call, and moving a slider (or sending /omniphony/control/backend/param) rebuilds with the new value.
Edit it live in Studio
With Script selected, the file parameter gets an Edit button that opens a Lua editor right in Studio. Save ships the script to the engine over OSC — into its managed backend-file store — and triggers a rebuild; a syntax or runtime error comes straight back as a recompute error in the status banner. The engine validates every script by compiling it, running setup, and probing gains at the origin before it goes live.
Why it’s safe to be “slow”
Lua never runs in the audio thread. The script backend is precompute-only: the engine runs your gains once per grid point on a build thread to bake a gain table, and the realtime path just samples that table. So a script can be as expensive as it likes at build time without ever touching the per-sample budget — the trade-off is that it can’t be a live realtime model the way native VBAP is.
Three worked examples ship in script-backends/ (nearest_inverse_distance.lua, vbap_blend.lua, vbap_subset.lua), with a README covering the full contract.
The full-power route: a small Rust crate — realtime-capable, no precompute restriction, compiled into the binary.
Start by copying
example_backend/— a minimal, heavily commented backend that depends on the renderer through its public API only and is built and tested in CI, so it always stays in sync.
The two traits
GainModel— the model itself: a stable id, a label, declared capabilities, a speaker count, and the hot-pathcompute_gains.BackendFactory— how the runtime builds your model: an id, a label, a declarative parameter schema, and abuild_planthat captures what it needs from the build context and returns a builder closure.
There is no BackendDescriptor or GainModelKind to extend — identity is a plain string id carried on the model and the factory.
The hot-path contract
compute_gains runs in the realtime audio thread, once per object per band per frame. It must:
- not panic — return a best-effort gain vector instead (e.g. zeroed);
- not allocate on the heap, lock, or block;
- return exactly
speaker_count()finite gains.
Do all expensive setup (triangulation, lookup tables, caches) when the model is built, not here — the build_plan closure runs on the build thread and may allocate freely. As a safety net the engine smoke-tests every freshly built backend on a few reference positions; a model that panics or returns a malformed vector is rejected at topology-build time (surfaced to Studio as a recompute error) instead of crashing the audio thread.
Declare honest capabilities
capabilities() declares only what your model actually supports — realtime vs precomputed evaluation, spread, distance model, table export, and so on. These flags drive which evaluation modes are available and which Studio sections appear, so don’t over-declare “for later.” Studio reasons with capabilities, never with if backend == vbap. (This is exactly how the Lua script backend declares itself not realtime-capable, so the host runs it in precomputed mode.)
Parameters are data
Tunables are declared in param_schema() as data (ParamSpec::float, ::int, ::bool). The host stores values generically and Studio renders the matching control (slider / checkbox / select) automatically. Values set over OSC (/omniphony/control/backend/param) or loaded from config are replayed into the store and trigger a rebuild, so your backend picks them up on the next build. (That address is part of the OSC protocol — nothing backend-specific to wire.)
Register it — the one line
A host registers your factory at startup:
control.register_backend(Box::new(my_backend::MyFactory));
After that line, selecting backend_id = "my_model" — from config, over OSC, or from the Studio dropdown — routes a topology rebuild through your factory. It then appears in Studio automatically: the runtime snapshot publishes the registry’s available backends (id, label, and parameter schema), and Studio populates its dropdown and generates the parameter controls from that — no per-backend JavaScript, no manual serde bridge.
Validate
cargo fmtcargo build --workspaceandcargo test --workspace(CI runs the same, including the build-time smoke test).- In Studio, select your backend and confirm its label, generated parameter controls, available evaluation modes, and section visibility.
- Change the layout and confirm a clean rebuild.
The full walkthrough lives in the repository’s docs/custom-render-backend-integration.md, alongside the example_backend crate.