Audio backends
The engine ships an audio API and no audio. What makes sound is a backend behind a Rust trait, exactly the way a scripting language is. See Script backends, whose shape this mirrors deliberately.
The bundled backend, renzora_audio_backend, is linked into the binary. You
do not install it and you cannot delete it: it is present whenever the engine was
built with its audio feature, and absent from the build entirely when it was
not. The trait it registers through is public, so a backend for a platform we
have not written one for can be installed as a plugin instead.
Why the backend is a separate crate
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. Build with
renzora_runtime's audio feature off and the binary contains no device layer,
no decoders and no DSP.
Why the bundled one is linked in rather than installed
It shipped as a loose audio.dll (.so, .dylib) for a while, and the size
argument above was the reason. What that form could not do is be reliably
present: every moving part of the API is inert without a backend, so a game
exported without the library beside it, or a player who deleted one file, got a
binary that ran perfectly and made no noise, with no error to explain it. A cargo
feature strips the mixer just as completely as a missing file did, and it cannot
go missing by accident.
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 (renzora_audio_backend) | 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 bevy::prelude::*;
use renzora::audio_backend::*;
#[derive(Default)]
struct MyMixer { /* … */ }
impl Backend for MyMixer {
fn name(&self) -> &str { "my_mixer" }
fn init(&mut self) -> Result<BackendInfo, String> { /* open a device */ }
fn load_clip(&mut self, clip: u64, extension: &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
}
pub struct MyAudioPlugin;
impl Plugin for MyAudioPlugin {
fn build(&self, app: &mut App) {
app.add_audio_backend(MyMixer::default());
}
}
renzora::plugin!(MyAudioPlugin, Runtime);
Five required methods. Capture, feeds, device enumeration, bus updates and clip unloading all have defaults, so a backend that only plays clips implements the above and reports the capabilities it actually has.
Backend is Send but not Sync: a mixer usually holds a lock-free producer
that is not shareable across threads, and nothing needs it to be. The engine
reaches it through &mut, so there is no lock on the path.
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 engine keeps the
first registration and logs an error on the second. Since the bundled backend is
linked in, it is the one already holding that slot on any build with audio on:
replacing it means building with the feature off, not adding a file.
The bundled backend
crates/renzora_audio_backend 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, through 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.