Audio
This file covers the from-scratch game-audio engine: the two-plane control/render split, the device seams, the real-time DSP core, the mixer and voice model, propagation and spatialization, reverb, the ECS bridge, the asset pipeline, and the editor’s authoring and telemetry surfaces.
1. Audio (from-scratch AAA game-audio, Phase S0)
Design: docs/design/AUDIO_SYSTEM.md. A from-scratch, middleware-free game-audio engine, placed
like skeletal animation and cosmetic VFX — a wall-clock snapshot consumer that lives outside
the deterministic sim island, so a run is byte-identical with audio on or off. Its shaping
decision is the split every shipping AAA runtime is built on: a control plane (the game/ECS
thread, which allocates freely and only publishes intent) and an audio-render plane (a
high-priority callback thread that mixes under hard real-time discipline — no heap allocation, no
locks, no syscalls). The real-time mix runs on the device callback and deliberately sidesteps
SushiRuntime, which is a block-until-quiescent throughput engine with no real-time thread
class; the runtime enters only through an optional, deferred GPU batch-DSP seam.
Phase S0 stands up the render-plane boundary end to end with a trivial renderer, before any DSP exists — the same “seams first, then fill” discipline the render and input stacks used.
The header-only seams (engine/domain/audio/include/SushiEngine/audio/) carry zero SDL, zero
SYCL, and no runtime link:
-
engine/domain/audio/include/SushiEngine/audio/device.hpp— the device I/O seam.AudioStreamFormatis the negotiated stream shape (sample rate, channel count, power-of-two block size); a backend may return a format other than the one requested, so a consumer always reads the obtained format back.IAudioRendereris the real-time render sink:render(float* const* channels, int channel_count, int frame_count) noexcept, called once per block on the audio thread with planar (deinterleaved) buffers — the discipline the DSP core keeps throughout, interleaving only at the device boundary.IAudioDevice(open/close/is_running/format) isolates the one unstable, platform-specific dependency so the whole mix is testable against a trivial renderer and the backend is swappable without touching a line of DSP. -
engine/domain/audio/include/SushiEngine/audio/accelerator.hpp—IDSPAccelerator, the optional GPU batch-DSP seam, declared now and implemented later (S10). It carries a singleavailable()query so a subsystem that could offload long convolution / HRTF / ambisonic decode asks first and falls back to its CPU path — which is every build today. Kept intentionally thin because the runtime’s fluent API is unstable; the batch-submit surface lands with the real implementation.
The compiled backend (engine/domain/audio/source/, sushiengine_audio_backend STATIC). The
mirror of sushiengine_input_backend: a plain STATIC library linking SDL2 and nothing else — no
SYCL, no runtime — so it builds on a stock toolchain and never touches the one-way
SushiEngine → SushiRuntime arrow. It carries the sole SDL-aware audio component,
Audio::SDLAudioDevice (engine/domain/audio/source/backend/sdl/sdl_audio_device.*), which
opens an SDL_AudioDevice and drives an IAudioRenderer once per block on SDL’s callback
thread: the planar scratch and channel-pointer table are allocated once in open (never in the
callback), the renderer fills them, and the device interleaves the result into SDL’s output
buffer. The OS handle crosses its header as an opaque std::uint32_t, so SDL never leaks into a
consumer translation unit.
A second backend, Audio::MaAudioDevice
(engine/domain/audio/source/backend/miniaudio/ma_audio_device.*), implements the same
IAudioDevice seam over miniaudio’s native low-latency APIs
(WASAPI/CoreAudio/ALSA/PulseAudio/…) as a drop-in alternative — one vendored public-domain
header, tighter buffer control, and the same allocate-in-open/interleave-in-callback
discipline; miniaudio’s types stay behind a pimpl so they never reach a consumer. The mix path is
identical either way — only the device I/O differs. samples/audio/audio_miniaudio_demo.cpp
opens a real device end to end.
The App::runtime() seam. Loop::App now exposes its owned or borrowed runtime — the one
handle the later GPU accelerator path needs to allocate USM. It does not weaken the one-way
dependency: the App still owns the lifetime, and a borrowed runtime is returned, never destroyed.
Gameplay never needs it; the loop still hides the runtime behind world(), commands(), and
system().
Layering & build. SUSHIENGINE_BUILD_AUDIO gates the compiled backend (OFF by default). The
samples/audio/audio_demo.cpp example is the S0 vertical slice: a headless, self-checking
software block loop (pump N blocks through a silence renderer, assert every sample is silent and
the renderer ran), then a best-effort real device open that is a clean no-op on a headless host.
se demo audio_demo builds and runs it (configuring SUSHIENGINE_BUILD_AUDIO=ON and
SUSHIENGINE_BUILD_EXAMPLES=ON, exactly as se render does for the renderer probe).
The DSP core (engine/domain/audio/include/SushiEngine/audio/dsp/, Phase S1). The portable,
real-time-safe C++17 layer beneath the seams — no SDL, no SYCL, no runtime — that every later
mix, filter, and spatializer is built on. It has no build option: like the input action layer it
rides the SushiEngine INTERFACE target and is exercised headlessly by Unit_Audio tests.
-
Real-time primitives.
ScopedNoDenormals(engine/domain/audio/include/SushiEngine/audio/dsp/denormals.hpp) is the RAII guard that sets flush-to-zero + denormals-are-zero on the FPU for the span of a callback — the fix for the slow subnormal arithmetic a decaying IIR or reverb tail would otherwise fall into; it restores the exact prior control word, and being outside the deterministic island the bit-level change is harmless.SpscRing<T>(engine/domain/audio/include/SushiEngine/audio/dsp/spsc_ring.hpp) is the one queue of the two-plane model: a power-of-two, wait-free single-producer/single-consumer ring with its two indices on separate cache lines and acquire/release publication — the control thread pushes command records, the audio thread drains them, neither ever locks. -
SIMD kernels (
engine/domain/audio/include/SushiEngine/audio/dsp/simd.hpp). The per-block hot loops —apply_gain,apply_gain_ramp(the zipper-free level change),mix_accumulate,copy_scaled,fill, and constant-powerequal_power_pan— as a 4-wide SSE path with a scalar remainder tail on x86 and a scalar fallback elsewhere. Applied to summed bus buffers, so they cost O(bus), not O(voice). -
Filters (
dsp/filters/).OnePole(the cheapest smoother / DC block / parameter ramp),Biquad(the RBJ cookbook set — low/high-pass, band-pass, notch, peaking, shelves — in the numerically well-behaved Transposed Direct Form II), andStateVariableFilter(Andrew Simper’s Cytomic TPT-SVF, the modulation-stable default that yields every mode from one pair of integrator states). Coefficients are designed indoubleoff-thread and storedfloat, the audio-path rule even though the engine’sScalaris double. -
Block graph (
engine/domain/audio/include/SushiEngine/audio/dsp/graph.hpp,engine/domain/audio/include/SushiEngine/audio/dsp/nodes.hpp).BlockGraphis a DAG ofNodeprocessors linearized by a Kahn topological sort computed off the audio thread;process()pulls one fixed block through that order and never allocates. Feedback is a connection flag, not a special node: a feedback edge is excluded from the ordering and, because each output buffer persists between blocks, the consumer reads the producer’s previous-block output — a one-block z⁻¹, which is exactly what lets a comb or reverb loop be computed in a single forward pass. Built-in nodes areSineNode,GainNode(per-block ramp),MixNode, andBiquadNode. Thesamples/audio/audio_dsp_demo.cppslice wires sine → mix → low-pass → gain and self-checks that the settled RMS proves the high tone was filtered out;Unit_Audio(tests/unit/test_audio_dsp.cpp) pins the ring, denormal flush, every filter, the SIMD kernels, the pan law, the topological order, and the one-block feedback exactly.
The action layer (engine/domain/audio/include/SushiEngine/audio/, Phase S2). The header-only
game glue over the DSP core — a prioritized multi-source mix — reached through the
engine/domain/audio/include/SushiEngine/audio/audio.hpp umbrella (now in
engine/include/SushiEngine/SushiEngine.hpp). Like the DSP core it has no build option and is
exercised headlessly by Unit_Audio.
-
Parameters (
engine/domain/audio/include/SushiEngine/audio/parameter.hpp). Every runtime mix change crosses the thread boundary the same way:SmoothedValueholds an atomic target the control thread publishes and the audio thread slews toward at a configured rate, bracketing each block as[start, end]for a click-free gain ramp — never a raw jump.Rtpclayers the “game variable → authored curve → target” mapping on top, evaluating the clamped piecewise-linearRtpcCurveon the control thread so the audio thread only ever ramps. -
Voices (
engine/domain/audio/include/SushiEngine/audio/voice.hpp). AVoiceSourcerenders mono and also offers a cheapadvance(skip forward without producing output) — the path a virtualized voice takes to keep its play position current for ~free.VoiceDescriptorcarries gain, priority, bus, pan, and a linear distance attenuation that reaches true silence, which is what makes it a clean culling signal.ToneSourceandBufferSourceare the S2 sources; decode/streaming is S8. A source that reportschannel_count() == 2also renders throughrender_stereo, and a 2D voice over one deposits channel for channel so its authored image survives; a spatial voice always takes the monorender, because a point source has one direction and encoding two channels from it is the same signal as encoding their sum.StereoBufferSourceis the two-channel source. -
Mixer (
engine/domain/audio/include/SushiEngine/audio/mixer.hpp). Voices sum into stereo buses, not into each other, so an effect runs once on a summed bus buffer (O(bus)) instead of per voice. ABushas a series insert chain (IBusEffect), a post-faderSmoothedValuegain, an output route into a parent, and aux sends that copy its signal at a level into a parallel bus (the reverb-send pattern).MixerGraphorders buses by a topological sort over the routing and send edges so every contributor precedes its consumer, with the master rendered last. A mono voice is placed into a bus byaccumulatewith an equal-power pan; a 2D stereo voice takesaccumulate_stereoinstead, which deposits channel for channel and applies no pan. Buses are stereo now; the ambisonic scene bus of the design doc’s §4 replaces that path at S4 without changing the routing/insert/send structure. -
Voice manager + engine (
engine/domain/audio/include/SushiEngine/audio/voice_manager.hpp,engine/domain/audio/include/SushiEngine/audio/engine.hpp).VoiceManagerholds a fixed pool and, each block, computes an effective audibility (base gain × attenuation) per active voice, ranks the set by(priority, audibility), promotes the top real voices up to a cap to full rendering, and leaves the rest virtual (position bookkeeping only) — so hundreds of possible sounds collapse to a bounded render set that pans into a handful of bus buffers. Real voices ramp their gain and free themselves when a one-shot ends.AudioEngineis theIAudioRendererthe device drives: it sets the denormal guard, clears the mixer, folds the voice manager’s real voices into the buses, runs the bus graph, and fans the stereo master out to the device channels.samples/audio/audio_mixer_demo.cppstarts 26 voices against a cap of 6 and self-checks the cap and the out-of-earshot virtualization; theUnit_Audiosuite (tests/unit/test_audio_mixer.cpp) pins the smoother, RTPC, aux-send arithmetic, post-fader gain, bus order, the real/virtual cap, distance culling, pan centring, and one-shot lifetime. Occlusion, the HDR window, a separate decode cap, and voice stealing layer onto this ranking in later phases.
Propagation (engine/domain/audio/include/SushiEngine/audio/propagation.hpp +
engine/domain/audio/include/SushiEngine/audio/dsp/fractional_delay.hpp,
engine/domain/audio/include/SushiEngine/audio/dsp/air_absorption.hpp, Phase S3). A source’s
whole travel through the air is modelled as one variable fractional delay line of length
distance/c, and the Doppler falls out for free: the read pointer sits that far behind the write
pointer, so when the distance changes between blocks the read rate stops being one — and a read rate
other than one is a pitch shift. This is why there is no velocity term anywhere; motion is
implicit in the frame-to-frame distance change. FractionalDelayLine does the read with a 4-point
cubic-Lagrange (Farrow) kernel (non-recursive, no ringing, clean to modulate).
SourcePropagation wraps it: it slew-limits the delay so a source cannot appear to break the
sound barrier (|v_radial| < 0.9·c), snaps the delay on a teleport instead of sweeping it (a
sweep would fire a synthetic Doppler screech), then dulls the block with a distance-driven
air-absorption low-pass and scales it by the distance gain. The air model is the full ISO 9613-1
absorption (air_absorption_db_per_meter from temperature/humidity/pressure) reduced to a
one-pole corner that falls with distance (air_absorption_cutoff); the speed of sound feeds both
the delay and the absorption from the same temperature, so delay, Doppler, and dullness stay
consistent.
DistanceModel (Linear/Inverse/Exponent) and the shared distance_attenuation give the rolloff,
used by both the audibility ranking and the rendered gain so a voice is culled by the level it
is played at. The voice manager runs a SourcePropagation per spatial real voice;
VoiceManager::set_voice_position is how a moving emitter (or, at S6, the ECS snapshot) drives
the whole effect. samples/audio/audio_propagation_demo.cpp flies a tone past the listener
(pitch up approaching, down receding) and tests/unit/test_audio_propagation.cpp pins the
delay-line accuracy, the ISO absorption, the distance laws, the delay ≈ distance/c, the Doppler
direction, and the teleport snap.
AudioEngine::render clamps its internal work to the prepared maximum block and zero-fills any
surplus device samples, so an OS mixer that hands back a larger callback block than prepare was
told degrades to brief silence rather than overrunning a buffer.
The spatializer (engine/domain/audio/include/SushiEngine/audio/spatializer.hpp +
engine/domain/audio/include/SushiEngine/audio/dsp/spherical_harmonics.hpp, Phase S4). The 3D
rendering core, and the reason the audio path scales: a source is placed by encoding it into a
shared ambisonic scene bus with its spherical-harmonic gains — (order+1)² gains, cheap, per
source — so any number of sources collapse into one fixed field. The harmonics are real AmbiX
(ACN ordering, SN3D normalisation, so W = 1 and the first-order channels are the direction’s
y/z/x), evaluated from the associated-Legendre recurrence.
The field is decoded once to a fixed 26-point virtual-speaker layout and each speaker is
rendered to the two ears through an analytic head model — a Woodworth interaural time difference
(a fractional delay per ear) and a head-shadow low-pass on the far ear — so the number of ear
renders is constant no matter how many sources play. Head tracking is free: the voice manager
encodes each source in head-relative coordinates (head_relative_direction rotates a world
direction into the listener’s forward/up frame), so a head turn re-aims every source with no
extra state. AudioEngine owns the spatializer, decodes it to binaural each block, and sums that
with the non-spatial stereo master; a spatial voice encodes into the scene bus instead of
stereo-panning when a spatializer is present, and the decode is skipped on a block with no
spatial source. samples/audio/audio_spatial_demo.cpp orbits a tone around the head (audibly
circling on headphones); tests/unit/test_audio_spatial.cpp pins the SH convention and
orthogonality, the encode/decode kernel, the left/right level cues, the ITD, front symmetry, and
head-tracking.
The analytic HRTF is self-contained (no measured data) and gives solid horizontal localisation and
externalisation. The measured-HRTF fidelity upgrade now slots in behind the same encode → decode
seam: engine/domain/audio/include/SushiEngine/audio/hrtf.hpp defines a dependency-free
IHRTFDatabase (an HRIR pair per head-relative direction) and HRIRConvolver (a per-ear
direct-form FIR), and BinauralSpatializer::set_hrtf_database switches each virtual speaker from
the analytic ITD + shadow model to a convolution through that direction’s measured impulse response
— capturing the pinna/torso cues the analytic model approximates.
engine/domain/audio/include/SushiEngine/audio/sofa_hrtf.hpp’s SOFAHRTFDatabase (behind HDF5,
off the umbrella like the SYCL accelerator) loads a real SOFA SimpleFreeFieldHRIR file
(Data.IR / SourcePosition / Data.SamplingRate), maps each source position to a head-relative
unit vector, resamples the taps to the stream rate, and serves the nearest pair; write_sofa
bakes the same datasets. Passing nullptr restores the analytic path, so the upgrade is strictly
additive.
samples/audio/audio_sofa_demo.cpp verifies it against the real MIT KEMAR set.
Above the per-speaker HRIR sits the MagLS decode
(engine/domain/audio/include/SushiEngine/audio/magls.hpp). MaglsBinauralDecoder solves, once at
configure time from a measured set, a pair of decode FIRs per ambisonic channel by least squares —
complex (exact ITD) below a cutoff, magnitude LS with phase continuation above it to suppress
the high-frequency coloration a finite order otherwise produces — and applies them straight to the
bus (channels × 2 convolutions, independent of the measurement count).
BinauralSpatializer::set_magls_decoder selects it above both the per-speaker HRIR and the analytic
model. AnthropometricHRTFDatabase personalizes any IHRTFDatabase by warping the impulse-response
time axis to the listener’s head size. samples/audio/audio_magls_demo.cpp.
Beyond the parametric FDN/convolution reverbs, ray-traced room acoustics
(engine/domain/audio/include/SushiEngine/audio/acoustic_raytracer.hpp) measures reverberation
from the geometry: RayTracedAcoustics Monte-Carlo path-traces bounces (specular/diffuse by
scattering coefficient, per-band absorption), bins arrivals at a receiver sphere, and
Schroeder-integrates the energy-time histogram into RT60 per band plus a decaying-noise impulse
response the ConvolutionReverb can render (verified within 4 % of Sabine for a shoebox).
maekawa_diffraction_db adds geometric edge diffraction (least-detour path over the occluder
silhouette → Maekawa insertion loss per band). samples/audio/audio_raytrace_demo.cpp.
The reverb (engine/domain/audio/include/SushiEngine/audio/dsp/feedback_matrix.hpp +
engine/domain/audio/include/SushiEngine/audio/dsp/fdn_reverb.hpp +
engine/domain/audio/include/SushiEngine/audio/reverb.hpp, Phase S5). The diffuse late field,
generated by an order-16 Jot feedback delay network: N = 16 delay lines are scattered into
each other every round trip by a lossless mixing matrix (Householder I − (2/N)·11ᵀ, or
Walsh–Hadamard), and the only loss in the loop is a per-line one-pole damping filter. That
split is the whole point — the orthogonal matrix sets echo density while the damping alone sets
decay, so RT60 becomes a clean knob: each line’s damping filter is solved so its DC gain is the
round-trip loss for the broadband RT60 and its Nyquist gain the loss for the (usually shorter) HF
RT60, giving frequency-dependent decay from one first-order filter.
Coprime prime line lengths keep the modes from piling into flutter, and a slow per-line
delay modulation (read through the cubic-Lagrange FractionalDelayLine) smears the survivors
— the standard cure for a metallic tail. Input Schroeder-allpass diffusion and a predelay
precede the network. Because the matrix is orthogonal and every damping filter is strictly
contractive, the FDN is unconditionally stable — it can neither ring forever nor blow up.
Above the DSP core, engine/domain/audio/include/SushiEngine/audio/reverb.hpp is the game-facing
layer: the IReverb seam (an FDN today, a convolution reverb later, same seam), the I3DL2 /
EAX parameter set with presets and the I3DL2→FDN mapping, a ReverbBusEffect that adapts any
IReverb onto a per-zone reverb aux bus as an IBusEffect (the S2 aux-send machinery routes
voices into it), and Sabine/Eyring room-geometry RT60 (Sabine below ᾱ = 0.3, Eyring above) with
a shoebox_reverb factory that turns a room’s dimensions and materials into an I3DL2 preset. Early
reflections (I3DL2 Reflections/ReflectionsDelay) are the image-source model delivered at S7 (see
below). samples/audio/audio_reverb_demo.cpp plays a percussive pattern into the aux bus;
tests/unit/test_audio_reverb.cpp pins the matrix losslessness, FDN boundedness, coprime lengths, a
measured T30 that tracks the requested decay, the darker-tail-as-HF-decays-faster behaviour, the
predelay, and the Sabine/Eyring maths.
The ECS bridge
(engine/world/simulation/include/SushiEngine/simulation/simulation.hpp +
engine/domain/audio/include/SushiEngine/audio/audio_scene.hpp +
engine/world/simulation/include/SushiEngine/simulation/audio_host_extract.hpp, Phase S6).
How the world starts making sound. Three trivially-copyable components carry the authoring:
AudioListenerParameters (the entity’s Transform + Orientation are the ears),
AudioEmitterParameters (a sound id plus the routing and attenuation a designer authors, pose
from Transform like the renderer reads), and ReverbZoneParameters (a world box plus an I3DL2
parameter set). Each is an archetype column, reached through the four IWorldEditor accessors so
a host needs no ECS header. None is read or written by a fixed-step Schedule system; instead a
read-only wall-clock extract — the audio sibling of the render extract() — walks them each
frame, so, exactly like skinning and VFX, it sits outside the deterministic island and a run is
byte-identical with audio on or off.
The extract does the two things the audio module cannot (it lives above the runtime, so it may):
it converts absolute double WorldVector3 positions to listener-local float with the
renderer’s eye-subtracted-in-double-then-cast idiom (planet-scale precision preserved), and reads
the listener’s Orientation quaternion into a facing frame — producing a plain-float
SceneSnapshot. That snapshot is reconciled against the live voice pool by AudioScene: start a
voice when an emitter appears, move and re-gain it while it persists (the frame-to-frame position
delta is the Doppler), push a changed looping flag onto the live source rather than rebuilding it
(so turning Loop off finishes the current pass instead of cutting or restarting the sound), stop
it when the emitter goes silent or is destroyed, and steer the reverb aux bus from the zone the
listener stands in.
The deliberate seam is Dependency-Inversion — AudioScene knows nothing about the ECS (it is plain
float, listener-local, unit-testable), and resolves a sound id to a source through an injected
IEmitterSourceFactory, the seam the S8 bank/event system fills. I3DL2Reverb was split into a
dependency-free engine/domain/audio/include/SushiEngine/audio/reverb_params.hpp so the
ReverbZone component carries the data without the reverb engine.
samples/audio/audio_scene_demo.cpp reconciles a hand-built emitter flyby;
tests/unit/test_audio_scene.cpp pins the reconciliation and tests/integration/test_audio_ecs.cpp
the extract against a real ECS world (listener-local conversion, zone containment, the read-only
invariant).
Occlusion, rooms + portals, and early reflections
(engine/domain/audio/include/SushiEngine/audio/acoustic_geometry.hpp +
engine/domain/audio/include/SushiEngine/audio/acoustic_material.hpp +
engine/domain/audio/include/SushiEngine/audio/occlusion.hpp +
engine/domain/audio/include/SushiEngine/audio/portals.hpp +
engine/domain/audio/include/SushiEngine/audio/early_reflections.hpp, Phase S7). The
geometry-coupled layer: what is blocked, and what that sounds like. Sound is occluded by a
dedicated acoustic BVH — coarse triangles tagged with three-band AcousticMaterials, not the
render mesh — built two-level like a ray-tracer: an AcousticBlas per mesh (built once) under an
AcousticScene top-level BVH over placed instances, so moving a rigid body is a transform plus a
cheap refit, never a BLAS rebuild.
It answers two questions: a single line_of_sight ray (accumulating each pierced surface’s
transmission — walls pass lows more than highs, so through-wall sound is bassy for free) and a
soft_occlusion sphere sample (a deterministic ray fan → a smooth 0..1 fraction, so cover fades
instead of popping). OcclusionFilter renders the result from the design’s two scalars:
obstruction (a pillar) muffles and attenuates the dry only, while occlusion (a wall) also
pulls the reverb send down — each slewed as an edge-diffraction coefficient.
Indoor propagation is the rooms + portals model (PortalGraph): a cross-room source is heard
through the doorways, each portal on a shortest path becoming a secondary virtual source at the
opening — cheap coupling and doorway diffraction without a wave solve. ImageSourceModel adds
first-order shoebox early reflections (the six wall images as delay/gain/direction taps),
rendered by EarlyReflections from a multi-tap delay line. The VoiceManager runs a per-voice
OcclusionFilter after propagation and sends the post-occlusion dry to a reverb aux bus scaled
by the occlusion wet-scale; new Room/Portal ECS components and an AUDIO_EMITTER_OCCLUDED
flag let the wall-clock extract soft-test each emitter against an AcousticScene and inject
doorway virtual emitters through a PortalGraph — still read-only, so a run stays
byte-identical. samples/audio/audio_occlusion_demo.cpp slides a tone behind a concrete wall;
tests/unit/test_audio_occlusion.cpp pins the BVH, transmission, soft fraction, the occlusion
DSP, the portal doorway, and the early reflections.
The asset pipeline: bank, codecs, events, and streaming
(engine/domain/audio/include/SushiEngine/audio/codec.hpp +
engine/domain/audio/include/SushiEngine/audio/event.hpp +
engine/domain/audio/include/SushiEngine/audio/bank.hpp +
engine/domain/audio/include/SushiEngine/audio/streaming.hpp, Phase S8). How authored audio gets
into the engine, and how the game plays it without ever naming a file. A bank is one compact,
versioned binary — a media table, the baked event/container definitions, and a blob of encoded sound
— that Bank loads and BankBuilder writes.
Each sound is stored in the codec that suits it behind the IAudioCodec seam: from-scratch
PCMCodec (16-bit/float) and ImaAdpcmCodec (a continuous 4-bit stream, ≈4:1, encoder and decoder
both here); OpusCodec (engine/domain/audio/include/SushiEngine/audio/opus_codec.hpp, behind
libopus) now adds compressed music/dialogue (≈31:1) over the bank’s own length-framed packet
container. Because the header-only bank cannot new a dependency-gated codec,
set_external_codec_factory lets an Opus-linked TU register it (register_opus_codec in one call)
and make_codec then serves Opus media transparently. Vorbis
(engine/domain/audio/include/SushiEngine/audio/vorbis_codec.hpp, behind libvorbis) is the same
pattern; the external-codec hook is a registry (add_external_codec_factory) so both coexist.
Both compressed codecs also stream from an IDataSource through the StreamingDecoder, and
decode_ogg_opus ingests external .opus files. The codec is a stateful forward decoder, so the
same code serves a resident one-shot (decode_all) and a chunked stream (byte-boundary carry).
The game posts an event ID; an EventDatabase of flattened
Sound/Random/Sequence/Blend/Switch containers resolves it to one media id (a
self-contained splitmix64 for the random variation) — the sound-designer indirection.
BankSourceFactory is the concrete IEmitterSourceFactory the S6 AudioScene was built to
accept: resolve → decode-once-cached → BufferSource. Long assets stream: a
StreamingDecoder (the lone producer) reads an IDataSource in chunks and pushes samples into
the S1 SpscRing, while a StreamingSource (the lone consumer, on the audio thread) only ever
pops — no I/O, decode, or allocation on the hot path; a StreamingWorker runs the pump on a
std::thread (tests pump synchronously, keeping the core deterministic).
samples/audio/audio_bank_demo.cpp fires footstep Random events over a streamed music bed;
tests/unit/test_audio_bank.cpp pins the codecs, the bank round-trip, container selection, the
factory, and the streaming path.
**The live-profiler telemetry channel (engine/domain/audio/include/SushiEngine/audio/profiler.hpp
- mixer/engine metering, Phase S9, part 1).** How the editor watches the audio thread without ever
locking it. The audio thread
publishes a fixed-size PODAudioProfileSnapshot— real/virtual/active voice counts, master peak/RMS, per-bus meters, a downsampled output scope, a monotonic block index — into anAudioProfileronce perrender; the GUI reads the latest whenever it repaints. The channel is a single-latest-value seqlock: the writer (audio thread) is wait-free and the reader only spins if it catches a publish mid-flight, so the hard-real-time rule holds (the design doc’s §0 one-way telemetry).
The MixerGraph now records each bus’s post-fader peak/RMS
(bus_peak/bus_rms), and AudioEngine gathers those plus the voice population and a scope of
the true output into the snapshot. This is the data half of the design doc’s §11 editor
authoring; the ImGui panels that render it — the mixer/bus strip, the emitter inspector, the
reverb-zone editor, the profiler view — are the remaining half of S9.
samples/audio/audio_profiler_demo.cpp polls the channel live off a running device;
tests/unit/test_audio_profiler.cpp pins the seqlock, the engine gather, and the real/virtual
split.
Editor audio authoring (simulation seam + applications/editor/source/audio/, Phase S9, part
2). The editor half of the design doc’s §11: a designer places an Audio Emitter, moves the
Scene camera, and hears it, with no game running. The sim seam gains editor-facing
AudioEmitterParameters/ReverbZoneParameters/AudioListenerParameters and the matching
IWorldEditor accessors, stored as archetype columns on the entity (and with no Audio
dependency in the simulation layer). The editor’s AudioEditorSystem owns a
live AudioEngine + SDL device with a small mixer (Master/SFX/Music + a reverb aux bus running
the FDN) and, each frame, projects the world’s emitters and the containing reverb zone into the
voice pool with the Scene camera as the listener. The default factory is
Asset::SoundLibrarySourceFactory, which plays the emitter’s own file; ToneFactory stays
reachable behind the Inspector’s Play audition, for an emitter with nothing assigned yet.
The engine is not on until something needs it. The editor scans the world twice a second and
opens the device the first time a scene holds an emitter, then stops scanning; an author who
toggled the Audio Mixer checkbox themselves has already answered and is never overridden. When
no output device opens, set_enabled reports false, the Log says so, and update returns
before it walks the world. The emitters keep their resolved handles either way, because the
resolve belongs to Frame::compose rather than to the audio system, so the session is silent
rather than broken.
The UI is an Audio Mixer
window (faders + live ImDrawList meters), the Profiler window’s Audio module (voice counts,
meters, output scope), and Inspector sections for the emitter (params + a live attenuation-curve
plot + a Play audition), the reverb zone (I3DL2 params + presets), and the listener — wired into
the Add Component and Window menus and the .scene serializer. The editor now links
sushiengine_audio_backend.
Procedural SFX, convolution reverb, and the GPU accelerator (audio/dsp/ + audio/, Phase
S10). The final phase, three independent pieces. Procedural SFX: modal synthesis
(engine/domain/audio/include/SushiEngine/audio/dsp/modal.hpp) — a bank of two-pole resonators
struck by an impulse, with material presets — wrapped as a ModalImpactSource (a one-shot ring) and
a WindSource (speed-driven filtered noise + a Strouhal Aeolian tone), both plain VoiceSources.
Convolution reverb: a from-scratch radix-2 FFT behind an IFourierTransform seam
(engine/domain/audio/include/SushiEngine/audio/dsp/fft.hpp), a uniformly-partitioned overlap-save
PartitionedConvolver (engine/domain/audio/include/SushiEngine/audio/dsp/convolution.hpp), and a
ConvolutionReverb (engine/domain/audio/include/SushiEngine/audio/convolution_reverb.hpp) — an
IReverb interchangeable with the FDN on the same aux bus, synthesising its room impulse response
from the I3DL2 parameters. GPU accelerator: SYCLDSPAccelerator
(engine/domain/audio/include/SushiEngine/audio/accelerator_sycl.hpp) is the concrete
IDSPAccelerator, offloading batch FIR convolution to the SushiRuntime SYCL device with k-block
lookahead (async submit / deferred collect over a USM slot ring) while the RT mix stays on the CPU —
the only place the runtime enters the audio subsystem, confined to that one SYCL-only header (kept
off the umbrella) and falling back to the CPU path when no device is present.
The sound asset chain (engine/asset/audio_asset/). What turns a file on disk into a voice.
The links, in order:
-
A
.wavunder the project root is a tracked extension in both hosts, soDatabase::AssetRegistry::rescanmints it a permanent id into a.metasidecar beside it. The list isAsset::supported_audio_extensions(), owned by the module that owns the format, the wayRender::supported_image_extensions()is. -
Dropping the file onto an entity writes that id to
Simulation::AudioEmitterParameters::sound_asset. That is the authored half, and the half the.sceneserializer round-trips. -
Frame::composestep 3 callsIWorldEditor::resolve_bound_soundswith the host’sSimulation::ISoundSource, which turns the id into a handle. It runs every frame, on the same terms as mesh filters and for the same reason: an emitter becomes resolvable when an import finishes or an entity is created, and no counter reports either. The steady state is one branch per emitter, exact becauseset_audio_emitter_parametersclears the handle whenever the authored id changes. -
Asset::SoundLibraryowns the parsed bank and the decoded samples for the session, keyed by asset id, and hands back theSoundIdthat lands inAudioEmitterParameters::sound— session state, never serialized. -
Asset::SoundLibrarySourceFactorybuilds the voice: aStereoBufferSourceover a two-channel sound, aBufferSourceover a mono one.
SoundId is a process-wide counter, never reused, not a positional index. TextureId and
MeshId are indices into their library’s vector, so a handle that outlives its library is in
range in a fresh one and addresses a different resource. Here it is simply absent and the voice
is silent. The asymmetry is deliberate: a wrong texture is a wrong picture, noticed on sight,
while a footstep that plays as an explosion has no golden image and no headless assertion behind
it — the only instrument is a human ear. The cost is a map lookup once per voice start rather
than an index per draw call per frame.
The library releases nothing before it is destroyed, because BufferSource borrows a raw
pointer into its samples and is read on the device callback thread.
What is refused, and how an author finds out. A-law, mu-law, ADPCM and MP3-in-WAVE are named
by Asset::WaveReadStatus and turned into a sentence by describe; SoundLibrary::refusal_for
keeps it against the asset id until that file loads, and the Audio Emitter inspector shows it.
“A-law is not supported” sends an author to a converter; “could not be read” sends them looking
for a corrupt file.
No cook exists. A .wav needs a parser, not a build step: turning 1 MB of PCM into 1 MB of
PCM buys nothing, and this project already declined to cook textures for the same reason. Every
byte enters the library through SoundLibrary::bank_bytes_for, which is the seam a later phase
replaces with a cooked/audio/<path>.sushibank read — where compression (Opus, IMA-ADPCM) and
streaming do earn a cook. Nothing above that function sees which branch filled the bank.
The player has audio. Frame::player_frame_input carries the sound source its host
hands it, and the player fills it: PlayerAudioSystem owns the library, the engine and the
device, and applications/player/source/player_app.cpp assigns state.sounds from it. The
root CMakeLists.txt turns SUSHIENGINE_BUILD_AUDIO on whenever SUSHIENGINE_BUILD_PLAYER
is on, and on the native lane a player configure fails outright without it. Both hosts walk
the world through one shared host-facing extract,
engine/world/simulation/include/SushiEngine/simulation/audio_host_extract.hpp, so an
authored sound is audible in a shipped game.
tests/unit/test_audio_asset.cpp and tests/unit/test_sound_library.cpp cover the reader, the
bank, and the library’s handle policy; tests/integration/test_sound_asset_resolve.cpp covers
the chain above through Frame::compose. None of them opens a device, so nothing in the suite
claims anything is audible.
With S10 the audio roadmap is complete: S0–S4 (the critical path) plus S5 (reverb), S6 (ECS integration), S7 (occlusion / rooms + portals / early reflections), S8 (asset pipeline), S9 (profiler telemetry + editor authoring), and S10 (procedural SFX + convolution reverb + GPU accelerator) all landed.

