Audio backends
The engine ships an audio API and no audio. What makes sound is a separate C-ABI plugin, exactly the way a scripting language is — see Script backends, whose shape this mirrors deliberately.
Drop audio.dll (.so, .dylib) into plugins/ and the game plays. Delete it
and the same binary runs silent: the mixer panel still shows a board, every
play_sound() still resolves, nothing panics. That is what "removable" means
here — not a feature flag, a file.
Why audio is a plugin and not part of the engine
Two reasons, and only the second is about size.
Different platforms need genuinely different implementations. The native
backend is cpal plus a mixer we wrote. A browser
backend cannot be: cpal's wasm hosts implement output but return an error from
build_input_stream_raw, so a browser build has no microphone at all through
that path. It wants WebAudio instead, where the graph, the panner and the
decoders come free from the browser and nothing has to be compiled into the wasm
blob. Those two share a contract, not a line of code.
A game that makes no sound should not carry a mixer. With the plugin absent, the binary contains no device layer, no decoders and no DSP.
What each side owns
The engine (renzora_audio) | the bus graph, the components scenes serialize, the command queue, the timeline, emitter bookkeeping, and all file I/O |
The backend (plugins/audio) | decoding, mixing, panning, distance attenuation, effects, the device, capture |
The split is the point: the backend speaks in handles, samples and bus keys, and knows nothing about entities, asset paths, transforms or the editor.
The host keeps file I/O, deliberately
A backend never opens a path. It is handed the bytes and an extension hint.
This is not tidiness. Exported and Android builds read assets out of an .rpak
archive through a loader the engine owns, so a backend calling std::fs would
work perfectly in the editor and fail in every shipped game — the worst possible
place for that difference to appear. It is the identical trap script backends
avoid for identical reasons.
Capabilities are answered, not assumed
Backend::init returns a Caps bitfield: CAPTURE, SPATIAL, FEEDS,
DEVICE_LIST. The engine will not ask a backend for something it did not claim.
Claiming honestly matters. A backend that says it captures and then does nothing produces a game that is silently wrong, which is worse than one that reports a missing feature.
Writing one
use renzora_plugin::audio::*;
use renzora_plugin::prelude::*;
#[derive(Default)]
struct MyMixer { /* … */ }
impl Backend for MyMixer {
const NAME: &'static str = "my_mixer";
fn init(&mut self) -> Result<BackendInfo, String> { /* open a device */ }
fn load_clip(&mut self, clip: u64, ext: &str, bytes: &[u8]) -> Result<ClipInfo, String> { … }
fn play(&mut self, request: &PlayRequest) -> Result<(), String> { … }
fn update(&mut self, request: &UpdateRequest) -> UpdateReply { … }
// everything else has a default
}
renzora_plugin::audio_backend!(MyMixer);
pub struct MyAudioPlugin;
impl Plugin for MyAudioPlugin {
fn build(&self, app: &mut App) {
app.add_audio_backend(audio_backend::desc());
}
}
renzora_plugin::add!(MyAudioPlugin);
Four required methods. Capture, feeds, device enumeration and clip unloading all have defaults, so a backend that only plays clips implements the four above and reports the capabilities it actually has.
One backend loads. Two scripting languages coexist because a script picks one by its file extension; there is no equivalent for audio, and a second backend would open the same output device and mix over the first. The host keeps the first registration and logs the second.
Ops, not one function pointer per operation
A backend registers a single extern "C" entry point, and the operation is
selected by an AudioOp code. A struct of thirteen named function pointers would
make adding a fourteenth an ABI break — every prebuilt backend would need
rebuilding to add, say, a new send.
With an op code, a backend that does not recognise one returns
AudioStatus::UnknownOp, and the engine treats that exactly as it treats a
capability that backend never claimed. Appending an op is a VERSION_MINOR bump
and nothing stops working.
The bundled backend
plugins/audio is the native one: cpal for the device, symphonia for decoding, and
our own mixer, spatialiser, reverb and delay. Its decoder support is per-project
— cargo features for ogg, wav (on by default), mp3 and flac — so a game
that only ships .ogg does not carry the MP3 and FLAC decoders.
Two things worth knowing about it:
- Everything is decoded up front. A three-minute stereo track costs roughly
60 MB resident as
f32. It buys a mixer with no I/O in it — no decode thread, no underrun path — and it is the only shape that works unchanged on wasm, where there are no threads to decode on. - The mixer runs on the audio thread, reached through a lock-free queue, and finished voices are handed back to be freed on the game thread. A mutex would have the device callback wait on a descheduled game thread, which is not a slow frame but an audible click.