From a consumer's side this is a control-only API: no audio buffers, no
device, no queue, no threads. You get an opaque engine handle, sounds, positioned
sources, and per-frame updates. Declarations in
include/bw_audio.h carry their contracts as comments;
examples/minimal.c runs the whole client lifecycle, orbiting a
click around the listener's head (every binding ships that same demo, and
integration.md says why the stimulus is a click);
examples/ambisonic.c walks the bed API (AmbiX/FuMa loading,
rotation/tilt, the renderer and max-rE A/Bs), examples/streaming.c
walks disk streaming + push sources, and examples/convenience.c
walks the convenience tier below, with each part naming the core calls it replaces. All four are
console programs built every build.
Terms used here without definition are in glossary.md.
- Listener-relative spatialization over the speaker array (the CAVE starts with 24
speakers and has room to grow; any layout of 4 to
BWA_MAX_CHANNELS(64) speakers works, and with no layout the engine runs the 26-speakerBWA_DEFAULT_GRID), recomputed per audio block from the tracked head position: DBAP for a moving listener (the default), SPCAP/VBAP for a fixed one, an optional dual-band mode. SPCAP's lobe width defaults to what the array's own speaker spacing implies. It is a live knob (bwa_set_spcap_focus) you can dial by ear. - Source extent: an angular spread, or a metric size in meters that holds as the listener
walks (
bwa_source_set_size). Optional velvet-noise decorrelation keeps wide sources from collapsing to a phantom image (bwa_set_decorrelation). A hole-aware spread floor covers directions the array has no speaker in (bwa_set_hole_spread), near-listener widening covers fly-throughs (bwa_set_near_spread), and a multi-listener compromise serves up toBWA_EXTRA_LISextra occupants (bwa_set_extra_listeners). - CAP: matches the rendered interaural time difference to a real source for the head's
current orientation (
bwa_set_dual_band_cap). It is the one place head orientation reaches the speaker path. - Per-speaker gain/delay/correction-EQ output stage driven by a measured layout
file, with a linked protection limiter as the final stage. Its LF room correction and,
opt-in, its whole time alignment can follow the tracked listener instead of one fixed
point (
bwa_set_tracked_room_eq,bwa_set_tracked_align), and with the speaker vendor's directivity balloon in the layout, each speaker's off-axis loss follows the head too (bwa_set_tracked_directivity). - Acoustics, any build: image-source early reflections (each wall bounce panned as a point source, so it has parallax as the listener walks; a shoebox or a bare ground plane, with pressure-release faces for water surfaces), a directional FDN reverb tail with live decay retune, manual occlusion with per-band transmission EQ, weighted-dipole source directivity.
- Acoustics, Steam Audio builds: ray-traced occlusion, a reflection bed (real-time or baked), sound pathing around occluders, and the HRTF binaural decode (without the SDK the headphone profiles fall back to a simple pan). Which reverb/reflection path to run is a real choice; see materials.md → "Choosing an acoustics path".
- Propagation (any build): Doppler, air absorption, equal-loudness compensation, near-field proximity, pitch; opt-in per source. The speed of sound itself is a live engine-wide parameter (underwater, slow motion).
- Assets: WAV/FLAC/MP3 decoded (and resampled) at load, disk streaming for long
files, push sources that take your own PCM into the engine (
bwa_source_push, a source feed, never a render path), and AmbiX or FuMa ambisonic beds (FuMa's mixed-order sets too) decoded world-locked to the array: a matrix decode (AllRAD, EPAD, or a two-band or mixed-order decoder imported from the Ambisonic Decoder Toolbox through the layout'sbed_decode) or a parametric renderer whose direct stream re-pans listener-relative (bwa_set_bed_renderer), with an orientation to line a capture up with the scene (bwa_bed_set_orientation). Two ownership tiers: explicit load/unload, and a refcounted by-path cache (bwa_sound_acquire,bwa_sound_release) keyed on path plus load flags, with an async twin for content that appears mid-session. - Source configuration as a struct: fill a preset for what the source is, override what you
disagree with, apply the lot in one call, and read back what a source is set to
(
bwa_source_preset,bwa_source_apply,bwa_source_get_desc). The engine-wide equivalent isbwa_tuning_preset/bwa_apply_tuning. - Voices: fixed pool with priority stealing, pause (per voice, per group, global) and
click-free seek, engine-side timed fades (
bwa_source_fade_to), mix groups for category gain and ducking (bwa_group_set_gain), gapless chaining into queued sounds (bwa_source_queue), click-free scene stops (bwa_group_stop,bwa_stop_all), a play region (bwa_source_set_region) that truncates a one-shot or loops a sub-range, loop-boundary events (bwa_poll_looped) beside completion events, sample-accurate scheduled starts and stops against a device-anchored DSP clock (bwa_source_play_at,bwa_source_stop_at). The device's own block stamps bridge that clock to wall time for AV sync, with the drift between the device and host clocks fitted in ppm for shows long enough to care.bwa_host_time_nsreads that host clock directly, with no engine, so a client measures the offset to its own clock to a bound it can see instead of estimating it. - Tracking: OptiTrack/NatNet ingested in-process; the audio thread samples the
freshest head pose at block time, with optional pose prediction to hide motion-to-ears
latency (
bwa_set_pose_prediction). - Headphones: a direct binaural render (
BWA_PROFILE_BINAURAL, no speaker-array simulation in the direct path) and an array-audition monitor (BWA_PROFILE_CAVE_SIM, the speaker bus through virtual speakers). Headphone correction EQ loads an AutoEqParametricEQ.txtand swaps in click-free (bwa_load_headphone_eq). - Offline: the manual sink drops the audio thread and lets you pump blocks yourself
(
bwa_render_block), bit-identically on every platform. Use it to pre-render stimuli that play through something else. - Diagnostics: per-channel test tone, direct output-channel routing of a real source
(
bwa_source_set_channel, the single-speaker reference condition), output-level and listener-pose readbacks, offline panner and bed-decode evaluation for layout tools (bwa_panner_gains_batch, which also takes SPCAP's focus and density knobs so a layout can be graded at the tuning it ships with,bwa_bed_gains_batch, andbwa_bed_gains_layout, which grades a layout file's own decode, imported matrix included, andbwa_bed_decode_matrix, which reads that decode back as a matrix for comparison with external tools).
bwa_desc cfg = { .profile = BWA_PROFILE_BINAURAL, .sample_rate = 48000,
.block_size = 256 };
bwa_engine* eng = bwa_create(&cfg);
if (!eng || bwa_start(eng) != BWA_OK) { /* see bwa_last_error(eng) */ }
bwa_sound steps = bwa_load_sound(eng, "footsteps.wav"); // once, at load time
bwa_source s = bwa_source_create(eng);
bwa_source_play(eng, s, steps, /*loop*/ true);
// per frame, from the control thread:
bwa_set_listener_pose(eng, hx,hy,hz, qx,qy,qz,qw); // skip if a tracker is connected
bwa_source_set_pos(eng, s, sx,sy,sz);
bwa_commit(eng); // ONE commit, last
// teardown:
bwa_source_destroy(eng, s);
bwa_unload_sound(eng, steps); // safe while playing: retire is acked internally
bwa_stop(eng); bwa_destroy(eng);- Production uses
BWA_PROFILE_CAVE, withcfg.layout_pathpointing at the surveyedcave_layout.json. A named layout that fails to load failsbwa_startwithBWA_ERR_LAYOUT(a wrong-channel-count session can't start silently);NULLopts into the default grid (BWA_DEFAULT_GRID, 26 speakers) deliberately. bwa_startnever demands hardware underBWA_SINK_AUTO: with no usable device the engine keeps rendering into a silent offline sink.bwa_get_audio_backendreports which backend you actually got (see Errors).- Completion is an event, and also a poll:
bwa_poll_endeddrains the handles whose voices finished since you last asked. Prefer it.bwa_source_is_playingis still there and still correct, but it is a per-block readback. A sound shorter than your frame interval can begin and end without you ever seeing it play.
The API has two tiers. Know the split before you read the rest.
The core tier is what everything else reduces to: bwa_create and bwa_start, an asset load
(bwa_load_sound and its siblings), bwa_source_create, the per-frame setters
(bwa_source_set_pos, bwa_source_set_gain, bwa_set_listener_pose), bwa_source_play, and
bwa_commit. That is the whole engine. The Quickstart above uses nothing else.
The convenience tier is everything that saves you writing the core calls yourself:
bwa_play_oneshot, the mix-group calls (bwa_group_set_gain, bwa_group_set_paused,
bwa_group_stop, bwa_stop_all), the asset helpers (bwa_sound_acquire, bwa_sound_release,
bwa_sound_acquire_async, bwa_sound_is_ready, bwa_sound_find), the source-description calls
(bwa_source_preset, bwa_source_create_desc, bwa_source_apply, bwa_source_get_desc),
bwa_tuning_preset with bwa_apply_tuning, and bwa_material_preset.
A convenience call is control-thread sugar. It runs on the control thread, writes the same command ring the core calls write, and lands in the same mixer. Nothing new reaches the audio thread and there is no second render path. Whatever one convenience call does, a handful of core calls do too, and the audio is identical either way. Read that as permission to mix the tiers freely, and as a warning not to look for extra behavior that is not there.
bwa_sound_acquire_async is the one call that adds machinery rather than only saving you typing: it
starts a background loader thread to decode off the control thread. That thread never touches the
audio thread or the mixer, so the guarantee above still holds. It blocks on an event rather than
polling, so it wakes exactly once per job and an idle session pays nothing for it. The decode
starts as soon as you call, not at some later tick; you still adopt the result at a pump point
(bwa_commit, or any acquire, release, find or bwa_sound_is_ready call).
Every voice is panned into an in-memory master bus, one channel per speaker
(24 on the CAVE array today; see Channel count). The profile selects
who consumes it (and, for BWA_PROFILE_BINAURAL, changes how point sources
render):
| profile | what renders |
|---|---|
BWA_PROFILE_CAVE |
bus → ASIO → Dante (production). Listener position only - real speakers, real ears. |
BWA_PROFILE_BINAURAL |
direct per-source HRTF render → any 2-channel device. Point sources skip the speaker panner. Full pose. |
BWA_PROFILE_CAVE_SIM |
bus → HRTF monitor → any 2-channel device (array auditioning): each bus channel is a virtual speaker in a simulated room. Full pose. |
BWA_PROFILE_CAVE_BOTH |
array to Dante + the CAVE_SIM monitor tap, concurrently. Two devices: device names the array, the monitor takes the platform default. |
BWA_PROFILE_BINAURAL is the first-class headphone render. It opens any 2-channel output
device (a WASAPI endpoint on Windows, or an ASIO driver). Point sources, and their ISM
reflections, skip the speaker panner. With the SDK each point voice gets its own true HRTF
convolution. Without it, each SH-encodes at its true listener-relative direction into a
shared field. Beds pass SH→SH and pathing sums in directly; only the FDN and reflection-bed tails
ride the bus as virtual speakers. So you get none of the array's phantom-source spread, and none
of the layout's per-speaker corrections: the align stage (trims, correction FIR, room EQ, tracked
alignment, directivity comp) is skipped, because no physical speaker exists. Render details:
spatialization.md.
BWA_PROFILE_CAVE_SIM places each bus channel as a virtual speaker at its surveyed room
position, DBAP artifacts included. You hear it from the live listener position through a
simulated room: each speaker's distance gain, propagation delay and (with a directivity model)
off-axis loss toward you. Head orientation turns the virtual array around you, and walking moves
you through it. Details: spatialization.md.
Pick by question, not habit: "what will the room do?" is CAVE_SIM; it hears
the same mix production plays, panner and all. "Best possible headphone
rendering of this scene" (demos, remote listening, development without the
array) is BINAURAL. See architecture.md.
The defaults target the CAVE: one tracked listener roaming the array. Change the setup and the right settings change with it, mostly because a sweet spot either exists or it doesn't. These are starting points. A/B them by ear in the playground.
Start from a tuning preset: bwa_tuning_preset(BWA_SETUP_ROAMING, ...) for a roamer,
BWA_SETUP_SEATED for a fixed seat, then bwa_apply_tuning. The presets carry the panner,
dual-band, CAP and every other live knob, with the evidence for each value, in
Situation tuning. This section adds what a
preset cannot carry: the profile, tracking, calibration, and the bed decoder. The calibration
commands are in calibration.md.
| tracked roamer (the CAVE) | fixed seat (one chair) | audience (several people) | desk (headphones) | |
|---|---|---|---|---|
| profile | BWA_PROFILE_CAVE |
BWA_PROFILE_CAVE |
BWA_PROFILE_CAVE |
BWA_PROFILE_CAVE_SIM (verify the array) or BWA_PROFILE_BINAURAL (best headphone render) |
| tuning preset | BWA_SETUP_ROAMING |
BWA_SETUP_SEATED |
BWA_SETUP_ROAMING |
either |
| panner | DBAP (preset) | SPCAP (preset); A/B VBAP | DBAP (preset) | any (DBAP) |
| tracking | bwa_tracker_connect |
none: set the pose once at the seat | track the main occupant, plus bwa_set_extra_listeners for the rest |
push the head pose, or track |
| dual-band | off (preset) | on, with CAP (preset) | off (preset) | your call (A/B it) |
| calibration | --eq + --room-eq-grid |
--eq + --room-eq at the seat |
--eq only |
--eq |
| bed decoder | AllRAD (default); A/B EPAD | AllRAD | AllRAD; A/B EPAD | either |
Tracked roamer. DBAP is listener-relative and re-solves every block, so the image follows you
instead of degrading away from a center. Leave dual-band and VBAP off: both sharpen the image at a
sweet spot, and there isn't one. Use the grid for room correction
(bwa_calibrate --room-eq-grid rows.txt, one placement per row). The engine interpolates the LF
cuts at your live position and glides the biquads. bwa_set_pose_prediction hides the panning
lag; start it at your measured motion-to-ears latency, typically 0.02 to 0.04 s. Decorrelation is
off in the preset because it measured worse at the sweet spot. For wide sources that comb-filter
as you walk, A/B bwa_set_decorrelation. A static room_eq layout in a moving session fails
bwa_start on purpose: one measurement point cannot correct a roam.
Fixed seat. Now a sweet spot exists, so spend it. The seated preset picks SPCAP: smooth,
all-speaker, and placement-correcting on an uneven array. VBAP gives the sharpest image if the
array triangulates cleanly, and falls back to DBAP if not; A/B it with bwa_set_panner.
Dual-band tightens bass localization. CAP holds the rendered interaural time difference as the
seated listener turns their head, and reduces to the plain panner when the head faces the source.
Calibrate with --room-eq with the mic at the seat. Don't track; set the listener pose once.
Apply the tuning before bwa_start: bwa_start refuses a room_eq layout while the panner is
still DBAP.
Audience. Panning is exact for one head and wrong for everyone else, so this is a compromise by
construction. Track the person who matters. Hand the other occupants' positions to
bwa_set_extra_listeners (up to BWA_EXTRA_LIS, 3): every source's gains become the
per-speaker energy mean of the per-listener solves, so each occupant gets an image biased toward
their own seat instead of one exact image and N wrong ones. Otherwise play it safe rather than
sharp: DBAP, dual-band off, and no room EQ beyond --eq. --eq flattens the speakers, not the
room, so it helps every seat. Raise bwa_source_set_spread on ambience: wide sources survive
off-center listening far better than points do.
Desk. Neither headphone profile needs a layout, Dante, or hardware beyond headphones.
BWA_PROFILE_CAVE_SIM renders the same speaker mix through virtual speakers, so what you hear is
the array render. Use it to verify what the room will do. BWA_PROFILE_BINAURAL renders sources
directly at their true directions: the better listen, the wrong probe. Don't use either to
judge timbre for the room. Load your headphones' AutoEq correction with bwa_load_headphone_eq
first, or you are judging the headphones.
Everywhere: the output limiter is on at -1 dBFS. Leave it on; it is speaker protection, not
mastering. Reflections are opt-in per source. Pick one reverb bed: Steam Audio's (needs the SDK,
ray-traced from your geometry) or the phonon-free FDN (bwa_fdn_config, a designed decay:
cheaper, works in no-SDK builds). Details in spatialization.md and
calibration.md.
- One control thread. All
bwa_*calls come from a single thread. For Unity/ Unreal that is naturally the main thread (LateUpdate/Tick), so single-producer holds for free. If you call from job threads, funnel them through one thread. - Non-blocking per-frame calls. Every source/listener update enqueues a command; it returns immediately and lands on the next audio block.
- Latest-wins. Position and pose are overwritten each frame; nothing accumulates or backs up. Push them every frame.
- Allocation only at load time.
create/start/load_sound/source_createmay allocate or do I/O. The per-frame loop is pure enqueue. bwa_commitdefines frame coherence. Position/pose updates land in pending fields; commit promotes them all to active as one snapshot, so the mixer never renders a moved listener against a not-yet-moved source. Once per frame, last (see Frame boundary).
The full model (rings, snapshot, lifetimes) is concurrency.md.
Room space is right-handed, meters, +y up, +z forward, origin on the floor at
the working-area center. integration.md →
"Coordinate seam" owns the frame: Motive's default streamed frame, the per-engine
conversions, and the handedness rationale. OptiTrack poses pass through unchanged, and
an identity orientation faces +z with the right ear at −x; derive basis vectors
from BWA_ROOM_AHEAD / BWA_ROOM_UP / BWA_ROOM_RIGHT in the header rather than
re-hardcoding them. The engine's world-locked decodes and its default listener
position use the array centroid (the nominal listening point), not the origin.
Gains are linear (1 = unity); sound offsets are engine-rate sample frames.
Coordinates have a range, not just a finiteness rule. Every call that takes a room position
(bwa_source_set_pos, bwa_set_listener_pose, bwa_set_extra_listeners, bwa_play_oneshot, and
the tracked head pose) accepts each axis in the range -1e6 to 1e6 meters. Outside that range, or
non-finite, the call is a no-op: the previous position stands, so a bad frame reads as a dropped
frame. The bound is not tidiness. Every spatial solve starts by squaring a coordinate difference,
so a value near the float maximum overflows to infinity, the normalization that follows turns that
into NaN, and the NaN sticks in the gain ramp and in the output-stage filter state for the rest of
the run. 1e6 meters is far past any real scene, so a position outside it is a caller bug.
Room geometry has its own bound. bwa_scene_set_ism_room, bwa_scene_set_box and bwa_box_mesh
take each dimension positive and at most 5e5 meters. A dimension is not a coordinate: the
image-source renderer mirrors a source across the faces, so an absurd dimension is an absurd image
position, and half the coordinate bound is exactly the factor that one mirror multiplies through.
Out of range, the two engine calls are refused with a reason in bwa_last_error and any room
already set stands. bwa_box_mesh takes no engine, so it reports by returning false and writing
nothing. It shares the bound for two reasons: the vertices it writes are room coordinates a caller
feeds straight to bwa_scene_set_mesh_mat, and a helper that accepted a box the engine refuses
would make the composition recipe below half-succeed. bwa_scene_set_ground takes the coordinate
bound instead, because a plane height is a room coordinate.
Where units live in a name. One quantity in this ABI has two live units: time is genuinely
both frames (the dsp clock, play_at, stop_at, seek, the playheads, the output latency,
bwa_sound_get_frames) and seconds (fades, RT60, IR length, the pose lead). So every time-valued
name says which: a getter ends _frames, a parameter is named seconds or ends _s. Nothing
else needs a suffix, because nothing else has a competitor: distances are meters, frequencies Hz,
angles radians, gains linear. Those carry the unit on the value where it helps
(radius_m, xover_hz, yaw_rad, host_time_ns) and never on the call.
The rule binds the bindings too, and there the risk is higher, because a host engine supplies its
own spelling of the same idea in the other unit. Unity's AudioSettings.dspTime is a double of
seconds and Godot's AudioServer.get_output_latency() returns seconds, so both bindings expose
DspTimeFrames / DspTimeSeconds and get_dsp_time_frames() / get_dsp_time_seconds() rather
than a bare name that reads as the host's. See integration.md.
start_sample, stop_sample and dsp_sample keep the word sample for the frames they count.
For a mono voice a sample is a frame, and the name already carries a unit, so it is not the bare
case this rule is about.
The one rule that constrains new calls: a decibel value must say _db. Linear is the unmarked
default across the whole ABI, so a dB parameter that doesn't say so is invisible.
Bindings inherit this and add one rule of their own: never borrow a host-engine name that carries
a different unit. Godot's AudioServer.get_output_latency() is seconds and
AudioStreamPlayer3D.seek() takes seconds, so the Godot binding spells its frame-valued twins
get_output_latency_frames / seek_frames and offers _seconds beside them.
bwa_source_set_region follows the same pattern: set_region_frames / set_region_seconds in
Godot, SetRegionFrames / SetRegionSeconds in Unity, and no bare spelling in either.
bwa_source_set_channel takes no suffix anywhere, because a channel index is not a quantity with
two live units.
Recipes for situations every client hits, composed from calls documented in the reference below; follow the links for the full contracts. The snippets are C. The Unity binding wraps each pattern (integration.md).
The recommended reflections-and-reverb stack, no Steam Audio build required: the six first-order wall bounces from the image-source model carry the early field, a directional FDN carries the late tail. You set up the room and the reverb once; only the motion is per-frame.
// load time: geometry and reverb are baked before start
bwa_desc cfg = { .profile = BWA_PROFILE_BINAURAL }; // CAVE on the rig
bwa_engine* e = bwa_create(&cfg);
bwa_material brick = bwa_material_preset(e, BWA_MAT_BRICK);
bwa_material carpet = bwa_material_preset(e, BWA_MAT_CARPET);
bwa_material faces[6] = { brick, brick, carpet, brick, brick, brick }; // -x,+x,floor,+y,-z,+z
bwa_scene_set_box(e, 8.f, 3.f, 10.f, faces); // the VIRTUAL room; before start
bwa_fdn_desc fdn = { .enabled = 1, .rt60_low_s = 1.4f, .rt60_high_s = 0.8f };
bwa_fdn_config(e, &fdn); // late tail; the Steam bed stays off
bwa_start(e);
// each source opts into the acoustics it needs
bwa_sound hum = bwa_load_sound(e, "drone.wav");
bwa_source s = bwa_source_create(e);
bwa_source_set_early_reflections(e, s, true); // wall bounces, panned with parallax
bwa_source_set_reverb(e, s, true); // feed the FDN's aux send
bwa_source_set_reverb_distance(e, s, true); // far = wetter
bwa_source_set_doppler(e, s, true); // it is going to move
bwa_source_play(e, s, hum, true);
// per frame: move it, and the acoustics follow
bwa_source_set_pos(e, s, x, y, z);
bwa_set_listener_pose(e, hx, hy, hz, qx, qy, qz, qw); // skip if a tracker is connected
bwa_commit(e);- The box is the virtual room, never the physical CAVE: the real room adds its own reflections, and modeling it double-counts (materials.md). Safe to call live: a room change re-solves the reflections next block.
- Materials are audible in any build. Each bounce is damped per band by its wall's absorption: the carpet floor kills the floor slap, the brick walls keep their treble.
- Only the position updates per frame. Reflection delays glide and gains ramp, so bounces bend instead of stepping, and the FDN send follows distance on the audio thread.
- Leave
bwa_reflections_configalone here. The Steam reflection bed also renders early reflections; running it with the ISM plays them twice (the engine warns once throughbwa_last_error). - The Steam Audio build adds occlusion on top: the same box feeds the ray-traced scene, so
bwa_source_set_occlusion(e, s, true)makes the walls block and muffle. Without the SDK, drivebwa_source_set_occlusion_manualfrom game logic. - Wet levels:
bwa_set_reverb_gainandbwa_set_early_reflections_gainare live, default- Opt in the few sources that matter: each opted-in voice costs six panner solves per block.
Your renderer decides now that something will be seen at wall time T (an animation
lands, a metronome flashes), and the sound must be heard at T. Wall time and the
dsp-sample clock are different clocks, so the recipe is: map T to a dsp sample using the
device's own block stamps (bwa_get_clock), subtract the device's render→DAC latency
(bwa_get_output_latency_frames), and schedule with bwa_source_play_at
(see Sources). Here in a raylib client;
GetTime() is the app clock, and any monotonic seconds clock works as long as you use
the same one throughout.
The one unknown in that mapping is the epoch offset between your clock and the engine's
host clock. bwa_host_time_ns reads the engine's host clock directly, so you measure the
offset instead of estimating it: sandwich one call between two reads of your own clock and
the error is bounded by half the span you just measured. There is no convergence period.
/* The wall<->dsp bridge. Two measurements and no estimator: the driver-stamped pair is
* exact, and the epoch offset between GetTime() and the engine's host clock comes from a
* sandwich around bwa_host_time_ns. Both are the same OS clock on Windows and macOS
* (QPC, mach_absolute_time), so the offset is stable and one measurement holds; measure
* again when you want a tighter bound or after the machine has been asleep. */
static double clk_off_ns; /* GetTime() nanoseconds -> engine host nanoseconds */
static double clock_measure(void) { /* returns the error bound, in ns */
double a = GetTime();
uint64_t h = bwa_host_time_ns();
double b = GetTime();
clk_off_ns = (double)h - (a + b) * 0.5e9;
return (b - a) * 0.5e9; /* the bound: half the round trip */
}
static uint64_t dsp_at(bwa_engine* e, double t) { /* GetTime() seconds -> dsp sample */
double fs = (double)bwa_get_sample_rate(e);
uint64_t cs, ct;
double d;
if (bwa_get_clock(e, &cs, &ct))
d = (double)cs + (t * 1e9 + clk_off_ns - (double)ct) * fs * 1e-9;
else
d = (double)bwa_get_dsp_time_frames(e) + (t - GetTime()) * fs; /* pre-stamp fallback */
return d > 0. ? (uint64_t)d : 0;
}Call clock_measure() once at startup and print the bound it returns: it is the accuracy
of everything downstream. The game loop then schedules against dsp_at:
const double display_s = 0.030; // draw -> photons: measure once, one constant
double anim_start = -1.;
while (!WindowShouldClose()) {
if (IsKeyPressed(KEY_SPACE)) { // swing starts now, impact lands in 0.5 s
double t_seen = GetTime() + 0.5 + display_s; // when the impact frame is SEEN
uint64_t heard = dsp_at(e, t_seen);
uint32_t lat = bwa_get_output_latency_frames(e); // render -> DAC, frames
bwa_source_play_at(e, s, thump, false, heard > lat ? heard - lat : 0);
anim_start = GetTime();
}
bwa_commit(e);
BeginDrawing();
// draw the swing; the impact frame renders at anim_start + 0.5
EndDrawing();
}- Schedule with margin. The play command lands on the audio thread at the next block; give the start a few blocks of lead (the 0.5 s swing has plenty). A start already in the past plays immediately, so the failure mode is graceful. But a spontaneous event ("the collision is this frame") can never beat the physical output chain: play it immediately and accept up to one output latency of error.
display_sis yours to measure. The engine reports its own output chain (bwa_get_output_latency_frames; the Digiface includes its Dante buffering) but cannot see your display's. Measure draw→photons once (photodiode, or an AV-sync clapper against the array) and that one constant aligns the whole chain.- The pre-stamp fallback is often enough. Before the first stamped block (and always on
the manual sink)
dsp_atdegrades to pairingbwa_get_dsp_time_frameswith your clock: block-granular, about 5 ms at 256/48 kHz, already under half a 60 Hz frame. The stamped pair buys sub-millisecond. - The other direction needs no wall clock. An event on the audio timeline (a cue
inside a track you scheduled) fires its visual when
bwa_get_dsp_time_framescrossesstart + cue, or offbwa_source_get_playhead_frames. - It holds for a two-hour show. The measured offset is constant while both clocks are
the same OS clock, and what still moves is the DEVICE against the host: read
bwa_get_clock_modelfor that, or re-measure now and then. See "Long shows drift" under Sources. - Unity:
emitter.PlayAt(engine.DspTimeFramesAt(tEvent))is this whole recipe (integration.md). Python:Engine.bridge()returns aClockBridgethat does the sandwich for you and exposesdsp_at,time_at,play_atand the bound it measured.
Is your clock the engine's clock? bwa_host_time_ns is the OS monotonic clock, and most
high-resolution clocks on the same machine are that clock. The offset method works either way.
What changes is how fast the offset goes stale: same clock means it never does, different
clocks means re-measure at whatever rate they drift apart, and a clock that NTP can step
means re-measure often.
| Clock | Windows | macOS | Linux |
|---|---|---|---|
bwa_host_time_ns (the engine) |
QPC | mach_absolute_time |
CLOCK_MONOTONIC |
Python time.perf_counter |
QPC | mach_absolute_time |
CLOCK_MONOTONIC |
Python time.monotonic |
GetTickCount64, about 15.6 ms |
mach_absolute_time |
CLOCK_MONOTONIC |
PsychoPy core.getTime |
QPC (it calls perf_counter) |
mach_absolute_time |
CLOCK_MONOTONIC |
Psychtoolbox GetSecs |
QPC | mach_absolute_time |
CLOCK_REALTIME |
Unity Time.realtimeSinceStartupAsDouble |
QPC | mach_absolute_time |
CLOCK_MONOTONIC |
Two rows need care. Python time.monotonic on Windows is a different clock with about 15.6 ms
of granularity, so a sandwich around it measures that granularity and nothing else: use
time.perf_counter. Psychtoolbox GetSecs on Linux is CLOCK_REALTIME, which NTP steps and
slews, so the offset moves under you: re-measure per trial, or take your timestamps from a
CLOCK_MONOTONIC source instead.
The fallback recipe. When you cannot call bwa_host_time_ns (an older library, or a host
whose clock only another process can read), estimate the offset from the block stamps instead:
each refresh observes offset - pair age, and a decaying max over those converges on the true
offset while tracking drift. It is what the Unity binding's DspTimeFramesAt does and it works
well. It just has a convergence period and no error bound, which is exactly what the direct
read removes.
static struct { bool valid; uint64_t sample; double host, off; } clk;
static void clock_refresh(bwa_engine* e) { /* once per frame, before any dsp_at */
uint64_t cs, ct;
if (!bwa_get_clock(e, &cs, &ct)) { clk.valid = false; return; }
double host = ct * 1e-9, cand = host - GetTime();
if (!clk.valid || cs < clk.sample || fabs(cand - clk.off) > 0.5)
clk.off = cand; // first pair / device restart / epoch change
else
clk.off = fmax(cand, clk.off - 2e-6); // decaying max
clk.sample = cs; clk.host = host; clk.valid = true;
}Call it once per frame before any dsp_at, and have dsp_at read clk.sample, clk.host
and clk.off instead of the live pair and the measured offset.
The profile is the only seam. For rig verification use BWA_PROFILE_CAVE_SIM: it
renders the same speaker-bus mix production plays, through virtual speakers at
the surveyed room positions
(Profiles and the master bus). Panning, acoustics,
and gain-staging bugs show up on headphones before the rig exists.
BWA_PROFILE_BINAURAL is the other headphone profile: the best-quality direct
render, for when headphones are the product rather than the probe.
// The rig's endpoint is an RME Digiface Dante. Its ASIO driver registers under RME's own
// name, which is not the product name - enumerate with bwa_get_device_count/_name (or
// bwa_calibrate --list-drivers) rather than hardcoding a guess. NULL opens the backend's
// default device, which is usually what you want anyway.
bwa_desc cfg = { 0 };
cfg.profile = rig ? BWA_PROFILE_CAVE : BWA_PROFILE_CAVE_SIM; // desk: audition the array render
cfg.layout_path = rig ? "cave_layout.json" : NULL; // desk: the default grid
cfg.device = rig ? rig_driver_name : NULL; // NULL = the backend's default
bwa_engine* e = bwa_create(&cfg);
if (bwa_start(e) != BWA_OK) { /* a rig layout that fails to load refuses to start */ }
printf("backend: %s\n", bwa_get_audio_backend(e)); // "asio:<driver>", "wasapi:<endpoint>", "null"
if (rig) {
bwa_tracker_desc trk = { .server = "192.168.1.42" }; // Motive drives the listener
bwa_tracker_connect(e, &trk);
}Everything else (assets, sources, the per-frame loop) is identical. The differences that matter:
- Pose. At the desk you push
bwa_set_listener_poseper frame, and orientation turns the virtual array around your head. On the rig the tracker overrides it, and the array render uses position only. - No device, still live. With no usable device the engine falls back to the silent
null sink and keeps rendering (playing states, playheads, and clocks all advance), so CI and
visual-only demos run unchanged. Name a backend in
cfg.sinkwhere silence would hide a failure. At the desk you rarely need to: a headphone profile opens a WASAPI endpoint, so the machine's ordinary output works with no driver to install. BWA_PROFILE_CAVE_BOTHis the rig plus a monitor tap: the array over Dante and headphones at the operator's desk at once (the tap is theCAVE_SIMaudition). It opens two devices, andsink,deviceandsink_flagsdescribe only the array. The monitor takes the platform's default stereo device, so on the rig you name the Digiface and get the headphones you are wearing without saying so. The exception is an explicitBWA_SINK_NULLorBWA_SINK_MANUAL, which the monitor follows so an offline render stays offline.
Composable patterns for physical effects that live in your code, built from calls the engine already has. Each is a few lines on the control thread; everything ramps, nothing blocks. The common thread: most "environment" effects are really per source-listener pair effects; apply them to each source whose path crosses the boundary, not as a master-bus wash.
Physics at an air-to-water boundary: ~30 dB of broadband transmission loss, water passes almost no treble, and localization collapses (only near-vertical rays penetrate; underwater interaural delays shrink 4.3x). Each maps to a knob. A bubbling vent next to the submerged listener needs none of this (its path never crosses the surface), which is why it's per source:
/* every source on the OTHER side of the surface, on submerge/emerge: */
void cross_surface(bwa_engine* e, bwa_source s, bool crossed) {
static const float water[3] = { 1.0f, 0.2f, 0.033f }; /* the TILT, relative to the level */
bwa_source_set_occlusion_manual(e, s, crossed ? 0.03f : 1.0f, crossed ? water : NULL);
bwa_source_set_spread(e, s, crossed ? 0.8f : 0.0f); /* direction goes diffuse */
}
/* the room-wide half, once per transition: */
bwa_fdn_set_decay(e, 3.0f, 0.3f, 800.0f); /* long LF tail, dead HF - hard walls under water */
bwa_set_reverb_gain(e, 1.5f);
bwa_set_speed_of_sound(e, 1480.0f); /* Doppler + reflection timing follow the medium */The level carries the 30 dB, so the triple only tilts it: the net is -30 / -44 / -60 dB, quiet but still there. Move the 30 dB into the triple as well and the low band lands at -41 dB, which reads as a dead source and an empty speaker meter.
Sources submerged with the listener keep playing untouched: the FDN and the speed of sound
already carry the medium. Resist the classic game pitch-drop: frequency is invariant across a
medium boundary, so it's an aesthetic, not physics (bwa_source_set_pitch if you want it anyway).
Hear the whole recipe live in the playground's Underwater scene (TAB to it; SPACE dives).
A surface seen from below reflects inverted (pressure-release, R ≈ -1): the flipped image cancels the direct sound near the boundary, which is why near-surface sources sound thin. Two assemblies, both live-safe (a mid-scene call re-solves the reflections next block, such as the submerge transition):
/* a bounded underwater room: the ceiling (+y) is the surface */
bwa_material faces[6] = { rock, rock, sand, 0 /*surface*/, rock, rock };
bwa_scene_set_box(e, 10.0f, 4.0f, 10.0f, faces);
bwa_scene_set_pressure_release(e, 1u << 3); /* +y reflects inverted */
/* open water: no walls, just the surface plane at y = 2 m, listener below it */
bwa_scene_set_ground(e, 2.0f, 0, true);Then bwa_source_set_early_reflections(e, s, true) on the sources near the surface: the comb
falls out of the image-source render. (The same bwa_scene_set_ground with false is the plain
outdoor recipe: the ground bounce, the one early reflection an open scene has.)
The aperture is a secondary source. Park a proxy at the door frame, play the far room's content on it, and the listener-relative panner does the walking-past for you:
bwa_source door = bwa_source_create(e);
bwa_source_set_pos(e, door, 2.0f, 1.2f, -3.0f); /* the frame's center */
bwa_source_set_size(e, door, 0.5f); /* the opening's radius: correct nearness */
static const float leak[3] = { 1.0f, 0.6f, 0.3f }; /* an opening, not a wall: mild HF loss */
bwa_source_set_occlusion_manual(e, door, 0.5f, leak);
bwa_source_set_reverb_send(e, door, 1.5f); /* it carries the far room's wet */With a Steam scene, bwa_source_set_pathing automates this (the sim finds the aperture); the
proxy is the no-SDK version, and also the authored version when you want control.
Upwind sources read quieter and duller. The engine has no weather model; your game does, so drive the same manual-occlusion knob from it per frame:
/* updot in [-1, 1]: how upwind the source is; strength in [0, 1] */
float loss = 1.0f - 0.4f * fmaxf(0.0f, updot) * strength;
float tilt[3] = { 1.0f, 1.0f - 0.3f * updot * strength, 1.0f - 0.6f * updot * strength };
bwa_source_set_occlusion_manual(e, s, loss, tilt);bwa_set_speed_of_sound(e, 70.0f); /* delays and Doppler stretch 5x: bullet-time acoustics */
bwa_source_set_pitch(e, s, 0.5f); /* content rate is a separate, aesthetic decision */Delays saturate at each voice's ring capacity (~40 ms at 48 kHz), so extreme factors cap; the pitch bends still track motion below that.
Not a boundary effect, but the same idea: finish the physics the panner starts. A close source should widen and gain body:
bwa_set_near_spread(e, 1.0f); /* geometric: sources widen inside 1 m (engine-wide) */
bwa_source_set_proximity(e, s, true); /* spectral: LF rises inside 1 m (per source) */bwa_engine* bwa_create(const bwa_desc* cfg);
bwa_result bwa_start(bwa_engine* e); // opens device(s), starts audio thread; BWA_OK = 0
bwa_result bwa_stop(bwa_engine* e);
void bwa_destroy(bwa_engine* e);
const char* bwa_last_error(bwa_engine* e);
uint32_t bwa_get_version(void); // the DLL's packed BWA_VERSION (major<<16|minor<<8|patch)
uint32_t bwa_get_sample_rate(bwa_engine* e); // resolved config, valid from create on -
uint32_t bwa_get_block_size (bwa_engine* e); // derive seconds from THESE, not from your desc
bwa_sink_type bwa_get_sink_type(bwa_engine* e); // the sink actually running (AUTO resolved at start)
const char* bwa_get_audio_backend(bwa_engine* e); // the same, human-readable: "asio:Digiface Dante"bwa_get_version lets a client verify the DLL matches the header it compiled against: compare it
with the header's BWA_VERSION, packed from BWA_VERSION_MAJOR, BWA_VERSION_MINOR and
BWA_VERSION_PATCH. The desc structs grow via reserved fields, but enum values and struct layouts
are only guaranteed within a major.minor. The resolved-config getters exist because every zero desc field means "default":
seconds = frames / bwa_get_sample_rate(e) is always right, even when the desc said 0.
bwa_get_sink_type is the machine-readable side of bwa_get_audio_backend: after a SUCCESSFUL
bwa_start, BWA_SINK_AUTO has resolved to the concrete backend that opened. Before start, after
stop, and after a start that FAILED it reports the configured policy instead, so a refused start
still answers the backend you demanded. Gate on bwa_start's result; this getter does not replace
it.
bwa_get_audio_backend is for logs and HUDs. After bwa_start it reads "<backend>:<device>",
with the device part in the OS's own UTF-8 name: "asio:Digiface Dante",
"wasapi:Headphones (Realtek Audio)". Three strings have no device part: "null" (the silent
offline sink), "manual", and "none" (not started). The headphone profiles append the decode
in use, because the HRTF decode falls back to the simple pan silently and a by-ear report needs to
know which ran:
BINAURAL:"(steam HRTF direct)"or"(simple-pan direct)".CAVE_SIMandCAVE_BOTH:"(steam HRTF sim)"or"(simple-pan sim)".
CAVE_BOTH opens two devices and names both, array first:
"asio:Digiface Dante + wasapi:Headphones (Realtek Audio) (steam HRTF sim)". Never parse this
string. Program logic uses bwa_get_sink_type.
Every pointer argument across the API is consumed before the call returns: no call retains
caller memory (bwa_create copies its path strings; descs, geometry, and position arrays are
copied at call time).
Zero-init bwa_desc and set what you need; every field's zero is its default:
| field | meaning |
|---|---|
profile |
cave / binaural / cave_sim / cave_both (see Profiles) |
layout_path |
surveyed speaker geometry (JSON); cave/cave_both. NULL = the default grid (BWA_DEFAULT_GRID) deliberately; a named file that fails to load fails bwa_start (BWA_ERR_LAYOUT) |
hrtf_path |
HRTF (SOFA) or NULL for built-in; the headphone profiles |
sample_rate |
Hz; 0 = 48000, the validated rate. The DSP is rate-derived and 96 kHz renders correctly in software, but rates above 48 k are unverified against the real Digiface/Dante chain - treat 48 kHz as supported until the rig confirms more |
block_size |
render quantum, frames; 0 = 256. Also the device period hint - a driver or an OS mixer may run its own size, and the sinks adapt, but the quantum handed to the DSP is exactly this regardless; bwa_get_block_size reads it back |
sink |
output-device policy: BWA_SINK_AUTO (0, default - see Sink policy). A named device backend is a demand, and an open failure fails bwa_start loudly: BWA_SINK_ASIO and BWA_SINK_WASAPI (Windows), BWA_SINK_JACK and BWA_SINK_ALSA (Linux), BWA_SINK_AAUDIO (Android, stereo only), BWA_SINK_WORKLET (browser, stereo only), BWA_SINK_COREAUDIO (macOS, not in this build yet). BWA_SINK_NULL forces the offline sink (CI, profiling, tracking-only). BWA_SINK_MANUAL creates no device and no thread: you pump blocks with bwa_render_block (deterministic, for golden tests) |
device |
device to open, for whichever backend opens it: the exact friendly name or the stable id from the device query. NULL = that backend's default (ASIO: the first registered driver with enough output channels; WASAPI: the Windows default render endpoint). Three backends read it differently: JACK takes a port regular expression, AAudio a decimal Android device id, and the worklet sink a decimal AudioContext handle (see Sink policy). Names the PRIMARY device only: under cave_both that is the array, and the monitor opens the platform default on its own. asio_driver is the legacy spelling of the same field |
embree |
ray-trace the acoustics sims on Intel Embree; silently falls back to the default tracer if the phonon build lacks it - see Ray-tracing acceleration |
enable_pathing |
run the sound-pathing sim from bwa_start (needs scene geometry + the Steam Audio build); sources opt in via bwa_source_set_pathing |
bed_decoder |
diffuse-bed SH→speaker decoder: 0 is the engine default (reserved, currently AllRAD), AllRAD (1), EPAD (2), or LAYOUT (3, the layout file's imported bed_decode) - see Panner and layout query |
sink_flags |
BWA_SINK_FLAG_* backend options for the PRIMARY device (they do not reach the cave_both monitor); 0 is the default, every bit opt-in. The flags and what each backend does: Latency classes |
reserved[3] |
zero; room to grow without an ABI break |
The API reports failure three ways, all read on the control thread:
- Pointer/handle returns.
bwa_createreturnsNULLon failure;bwa_load_sound/bwa_source_createreturn0. bwa_resultreturns.bwa_start,bwa_stop,bwa_tracker_connectandbwa_load_headphone_eqreturnBWA_OK(0) on success, a nonzero code otherwise: thebwa_resultenum ininclude/bw_audio.h, listed below.bwa_last_error. A human-readable string for the most recent failure on that engine, orNULLif none. Lifetime: the engine clears it at entry to the lifecycle/load-class calls (create, start, the loads, the source creates, tracker_connect), so a successful one leaves itNULL. A per-frame call sets it only when it REJECTS the call. Read it right after the call you are checking; a later successful lifecycle call wipes it. The string itself stays valid until the nextbwa_*call on the same engine.
| code | value | cause |
|---|---|---|
BWA_OK |
0 | success |
BWA_ERR_CONFIG |
1 | bad call arguments (a NULL engine or desc), a static-room_eq layout in a moving-listener session, or an unparseable headphone EQ file |
BWA_ERR_DEVICE |
2 | ASIO/output device could not be opened, lacked enough output channels for the layout, or failed to start |
BWA_ERR_LAYOUT |
3 | bwa_start: an explicitly-passed layout_path failed to load at create (missing/unparseable/failed validation - see layout-schema.md) |
BWA_ERR_HRTF |
4 | reserved; not currently returned. HRTF (SOFA) load failures are non-fatal - the monitor degrades and the reason lands in bwa_last_error |
BWA_ERR_STATE |
5 | reserved; not currently returned. Wrong-state calls report through bwa_last_error instead |
BWA_ERR_INTERNAL |
6 | reserved; not currently returned |
BWA_ERR_TRACKER |
7 | bwa_tracker_connect failed to open the NatNet session (socket open, a rigid-body name that did not resolve) |
What actually comes back today:
bwa_createreturnsNULLfor a NULL desc or an allocation failure. No code. It does not validate the desc fields: zerosample_rateandblock_sizetake their defaults, and nothing else is checked.bwa_startreturnsBWA_OK,BWA_ERR_CONFIG(NULL engine, or aroom_eqlayout in a moving-listener session),BWA_ERR_LAYOUT(below), orBWA_ERR_DEVICE. A redundantbwa_startis a no-op returningBWA_OK.bwa_stopreturnsBWA_OK, orBWA_ERR_CONFIGfor a NULL engine.bwa_tracker_connectreturnsBWA_OK,BWA_ERR_CONFIG(NULL args / a static-room_eqlayout), orBWA_ERR_TRACKER.bwa_load_headphone_eqreturnsBWA_OK, orBWA_ERR_CONFIG(NULL engine, or a file that fails to read or parse; the previous EQ stays).- A failed explicit
layout_pathleavesbwa_createusable (the engine sits on the default grid; the reason is inbwa_last_error, readable right after create) but failsbwa_startwithBWA_ERR_LAYOUT: a session that named a layout never silently runs the default grid's channel count and geometry.layout_path = NULLmeans the default grid deliberately. - A bad
hrtf_pathdoes not failbwa_start. The monitor degrades to the simple pan and records why inbwa_last_error; if your session depends on a SOFA HRTF, readbwa_last_errorafter a successfulbwa_startto confirm it loaded.
Per-frame calls that reject the call do set bwa_last_error (bwa_source_push on a plain
source, bwa_source_queue, bwa_play_oneshot on a dead handle, bwa_fdn_set_decay,
bwa_material_release, bwa_apply_tuning); a per-frame call that merely enqueues never does.
Per-frame void calls that only enqueue (bwa_source_set_pos, bwa_commit, …) report nothing. They
enqueue onto the command ring and return. A full ring drops the command silently, but the ring is sized
(RING_CAP) for a worst-case frame burst, and only a stalled control thread could fill it
(see concurrency.md). Position and pose are latest-wins, so a dropped
update is corrected one frame later.
There are none. You configure everything through the API: the output device is
bwa_desc.sink/device, Embree is bwa_desc.embree, pathing is bwa_desc.enable_pathing,
reflection baking is bwa_reflections_desc.bake, and the OptiTrack connection is
bwa_tracker_connect. One door per knob.
The occlusion and reflection sims ray-trace on the Steam Audio scene. Set embree = true
to run them on Intel Embree instead of Steam Audio's built-in
ray tracer: faster, and the lever to pull if you raise scene complexity, ray counts, or bake at
high probe density. It is opt-in and safe: if the linked phonon was not built with Embree
(or the Embree/TBB runtime is missing), the engine logs that Embree is unavailable and falls back
to the default tracer (no failure). The engine creates the scene once and both sims share it, so the flag
applies to occlusion and reflections together. The phonon this repo builds and links is not
Embree-enabled (the recipe passes --minimal), so the flag currently falls back; to activate it,
build phonon with the SDK's STEAMAUDIO_ENABLE_EMBREE path and ship embree4.dll + tbb*.dll
alongside.
bwa_desc.sink decides which device backend opens. The full backend design, and the ones still
to come, are in backends.md.
BWA_SINK_AUTO (the default) picks per platform, and on Windows also by channel count, because
there the two output paths want different hardware:
| request | Windows order | Linux order | Android order | browser order |
|---|---|---|---|---|
2 channels (headphone profiles, the cave_both monitor) |
WASAPI, ASIO, null | JACK, ALSA, null | AAudio, null | worklet, null |
| more than 2 channels (the array) | ASIO, null | JACK, ALSA, null | null | null |
The width does not split the Linux order, because nothing on Linux carries the "this transport is
the array's" decision that ASIO carries on Windows: the same backend serves two channels and
twenty-six. JACK goes first because it is the Linux production path, and jack_client_open carries
JackNoStartServer, so a box with neither a JACK server nor PipeWire falls through to ALSA in
about 4 ms instead of spawning a jackd. Android and the browser carry no array transport, so a
request wider than two channels goes straight to the offline sink there. macOS runs the null sink
until the CoreAudio backend lands.
A headphone profile on Windows therefore opens the Windows default output, which is the device the headphones are on, and never lands on the first registered ASIO driver (on a rig machine, the Digiface). The array never chooses WASAPI, because the array's transport is a settled decision (architecture.md).
cave_both resolves on its own from that: the array takes ASIO and the monitor takes WASAPI, so
the profile opens both devices at once. The ASIO SDK allows one driver per process, so the monitor
cannot be a second ASIO device. The monitor asks for BWA_SINK_AUTO with no device name, because
device belongs to the array: a name that matches no WASAPI endpoint is skipped by the rule
above, and the monitor would fall to silence. bwa_get_audio_backend names both devices under
this profile, array first.
Naming a backend is a demand. BWA_SINK_ASIO, BWA_SINK_WASAPI, BWA_SINK_JACK,
BWA_SINK_ALSA, BWA_SINK_AAUDIO and BWA_SINK_WORKLET fail bwa_start loudly when the device
does not open, instead of falling back to silence. A backend this build does not carry
(BWA_SINK_COREAUDIO anywhere, the Linux pair off Linux, the Windows pair off Windows,
BWA_SINK_AAUDIO off Android, BWA_SINK_WORKLET outside an Emscripten build with the worklet
sink) fails with a message that says so.
BWA_SINK_AAUDIO is stereo only. Android carries no array transport, so a request wider than two
channels fails the open with a message naming the width, and AUTO on Android sends one straight to
the silent offline sink. Its bwa_desc.device is a decimal Android device id from Java's
AudioManager, not a name: AAudio exposes no device list to native code, so the device query
reports one entry, default, with id 0.
BWA_SINK_JACK reads bwa_desc.device differently from every other backend, because JACK has
ports rather than devices: there it is a jack_get_ports regular expression, such as
system:playback_ or RAVENNA:playback_, and NULL connects to the physical playback ports in
order. The connections are a starting point. The patchbay can rewire them live, and the sink never
reasserts them.
BWA_SINK_WORKLET is the browser's Wasm Audio Worklet, and it is stereo only for the same reason
as AAudio. Its bwa_desc.device is a decimal AudioContext handle, not a name, because Web Audio
enumerates no output devices. Pass the handle of a context your page created, so the page keeps
the right to resume it; NULL makes the sink create its own. A suspended context is not a failure:
the engine keeps rendering silence and reports device_lost until the page resumes it. The web
binding and its two topologies are in web.md.
bwa_desc.device names the device for whichever backend opens it. It matches exactly against
the friendly name, then exactly against the stable id: no substring matching, because two
endpoints called "Speakers" are common and a picker hands back an exact string. Under AUTO, a
backend that has no device by that name is skipped rather than opened on its default, so the name
reaches the backend that owns it.
Sample rate. The array demands its rate: the device runs at bwa_desc.sample_rate or the open
fails, since a resampled array shifts every per-speaker delay. A headphone sink may let the OS
resample, and then reports the degradation through bwa_last_error after a successful
bwa_start. Check it there.
bwa_last_error carries the other AUTO degradations too, for the same reason. A start that fell
through to the silent offline sink says why the real device did not open, and a device name no
compiled backend has says that rather than opening a default. Both are successful starts, so the
message is the only thing that tells you the room is about to be quiet.
bwa_desc.sink_flags holds four opt-in bits. The first three trade something a shared,
resampling, comfortably buffered device gives you for free; the fourth trades latency for margin:
| flag | what the backend does |
|---|---|
BWA_SINK_FLAG_EXCLUSIVE (0x1) |
WASAPI opens the endpoint in exclusive mode, AAudio asks for the MMAP path. ASIO, JACK and a hw: ALSA PCM are already exclusive, so it is a no-op there |
BWA_SINK_FLAG_EXACT_RATE (0x2) |
the open fails, naming both rates, when the device cannot run at sample_rate. WASAPI shared mode drops its auto-convert flag, ALSA turns the library resampler off, AAudio checks its device-rate probe. The array sinks behave this way already |
BWA_SINK_FLAG_TIGHT_BUFFER (0x4) |
the smallest device buffer the backend can take: ALSA asks for 2 periods instead of 3, AAudio for 1 burst instead of 2. WASAPI exclusive mode already runs one period and shared mode's buffer belongs to the OS mixer, so it is a no-op there, and so are ASIO and JACK |
BWA_SINK_FLAG_DEEP_BUFFER (0x8) |
a DEEPER OUTPUT BUFFER: more latency, more slack for a render that overruns its callback. The browser sink only: a context it creates gets latencyHint "playback" instead of "interactive" (on a Windows desktop 20 ms of browser buffer instead of 10, 1216 frames of output latency instead of 736 at block 256). A context the PAGE hands over keeps the hint the page gave it, so a page that wants this creates its AudioContext with the hint itself; the web XR page uses 0.05 s. A no-op on every other backend |
None of them reaches the cave_both monitor. Like device, they describe the primary device, and
an exclusive grab is a decision about the array's interface rather than about the machine's
headphone endpoint.
If you are porting a psychophysics rig, PsychToolbox's PsychPortAudio('Open', ..., reqlatencyclass) maps onto these. The tools take --latency-class N and set both columns:
| PsychPortAudio class | flags | block_size |
|---|---|---|
| 0, do not care | none | 512 |
| 1, low latency | none | 256 |
| 2, aggressive | EXCLUSIVE |
256 |
| 3, critical | EXCLUSIVE + TIGHT_BUFFER |
128 |
| 4, match the format | EXCLUSIVE + TIGHT_BUFFER + EXACT_RATE |
128 |
Classes 0 and 1 are shared mode with the OS free to convert, which is what PsychPortAudio's
paWinWasapiAutoConvert does. Class 4 adds the strictness it spells
paMacCoreFailIfConversionRequired on macOS and eStreamOptionMatchFormat on Windows: fail rather
than convert.
The block size is a hint. Read the result back rather than assuming it:
bwa_get_output_latency_frames reports the real render-to-DAC delay.
Measured on one desk machine, a Realtek onboard endpoint at 48 kHz, with
bwa_get_output_latency_frames:
| open | latency |
|---|---|
| shared, 256-frame blocks | 736 frames, 15.3 ms |
| exclusive, 256-frame blocks | 544 frames, 11.3 ms |
| exclusive, 128-frame blocks | 416 frames, 8.7 ms |
| exclusive, 64-frame blocks | 352 frames, 7.3 ms |
Two seconds of each ran with no dropout. Your endpoint will differ: this driver forces a 144-frame exclusive period whatever the engine block is, so most of what the smaller blocks buy is the adapter holding less.
Exclusive mode takes the device. While the engine holds it, no other application on the
machine can open that endpoint: not the VR runtime, not the browser, not the OS sounds. On a
headset that is usually wrong, because the runtime needs its own audio. On a dedicated rig it is
usually right. TIGHT_BUFFER is the same kind of trade in the other direction: it removes the
margin that absorbs a late block, so expect dropouts on a loaded machine.
uint32_t bwa_get_device_count(bwa_sink_type backend);
bool bwa_get_device_name(bwa_sink_type backend, uint32_t index, char* buf, uint32_t cap);
bool bwa_get_device_id (bwa_sink_type backend, uint32_t index, char* buf, uint32_t cap);
// the ASIO-only spelling, kept for existing callers:
uint32_t bwa_get_asio_driver_count(void);
bool bwa_get_asio_driver_name(uint32_t index, char* buf, uint32_t cap);Enumerate what a backend can open: call before bwa_create to populate a device picker, then
pass the chosen string as bwa_desc.device. backend must be concrete; BWA_SINK_AUTO,
BWA_SINK_NULL and BWA_SINK_MANUAL report 0 devices, and so does a backend this build does not
carry.
Both strings are UTF-8 and both are accepted by bwa_desc.device. Prefer the id when you
persist a choice: friendly names collide (a headset and its dock often share one) and change when
a driver updates. ASIO has no separate id, so it reports the driver name for both.
Index 0 is the platform default where the backend has one. WASAPI's index 0 is the Windows default render endpoint, and the rest follow with that one not repeated. ASIO has no default, so index 0 is just the first registered driver.
Nothing is loaded or opened, and the list is read fresh on every call, so a driver or endpoint
that appeared since the last call shows up and the query is safe alongside a running engine. A
listed device is not necessarily openable (hardware unplugged, exclusive-mode busy); the truth
test is still bwa_start + bwa_get_audio_backend. The tools ride the same query: --list-devices
on bwa_playground and bwa_minimal lists every backend, with --list-drivers kept there as a
deprecated ASIO-only alias; bwa_calibrate, bwa_validate and bwa_zylia_probe are ASIO-only by
nature and keep --list-drivers. The GUIs use picker dropdowns.
The buffer you pass is yours to size. A name longer than cap is truncated to cap - 1 bytes on a
UTF-8 character boundary and still returns true, so a picker that sized for the common case gets a
short name rather than an empty one.
bwa_sound bwa_load_sound(bwa_engine* e, const char* path); // decode fully into RAM; 0 = failure
bwa_sound bwa_load_sound_streaming(bwa_engine* e, const char* path); // stream from disk (long files); 0 = failure
void bwa_unload_sound(bwa_engine* e, bwa_sound snd); // safe; retire-acked internally
uint64_t bwa_sound_get_frames (bwa_engine* e, bwa_sound snd); // length in frames at the ENGINE rate
uint32_t bwa_sound_get_channels(bwa_engine* e, bwa_sound snd); // 1 = mono; 4/9/16 = ambisonic bedLoad sounds once, at load time. WAV, FLAC, and MP3 are accepted (decoded to mono float by dr_libs, dispatched by file extension). If the file's sample rate differs from the engine's, it is resampled to the engine rate at load (a windowed-sinc pass), so a 44.1 kHz MP3 plays correctly on a 48 kHz engine; you pay only the one-time load cost.
bwa_load_sound_streaming is for long assets (music, ambience) you don't want resident in RAM: a
background thread feeds the voice from disk as it plays. It is mono, at the engine sample rate
(a rate mismatch fails; pre-convert, or use bwa_load_sound which resamples), plays on one voice
at a time, and does not support bwa_source_seek (the ring can't jump).
bwa_unload_sound is safe to call at any time. The core detaches references on the audio thread
and frees only after the retire-ack (see concurrency.md); it never pulls
a buffer out from under a playing voice.
The metadata getters answer "what did I just load" (progress bars, validation, mono-versus-bed
dispatch). Frames are at the engine rate (in-memory assets were resampled at load, streams
must already match), so seconds = frames / bwa_get_sample_rate(e). get_frames returns 0 for an
invalid handle or a stream whose length is unknown (push sources); get_channels returns 0 for
an invalid handle. Control thread, any time after the load.
typedef enum {
BWA_LOAD_STREAM = 1 << 0, // as bwa_load_sound_streaming
BWA_LOAD_AMBIX = 1 << 1, // as bwa_load_ambix
BWA_LOAD_FUMA = 1 << 2 // as bwa_load_fuma
} bwa_load_flags;
bwa_sound bwa_sound_acquire(bwa_engine* e, const char* path, uint32_t flags);
void bwa_sound_release(bwa_engine* e, bwa_sound snd);The four bwa_load_* calls are the explicit tier: you loaded it, you unload it, once. That
tier does not dedup and does not count references, so every client ends up writing the same
path-to-handle dictionary. bwa_sound_acquire is the shared tier over those same loaders.
The cache key is the path plus the flags. Ask for the same key twice and you get the same handle
with one more reference. Ask for the same path with different flags and you get different
entries, because a file held in RAM and the same file streamed are two different assets. Flags 0
means bwa_load_sound.
On Windows, paths compare case-insensitively and / and \ are equivalent. Neither fold
applies on Linux or macOS, where A.wav and a.wav are different files and a backslash is an
ordinary character in a name, so folding them would hand one cache entry to two files. Nothing
else is normalized on any platform: ./sound.wav and sound.wav are two entries. There is no
.. collapse and no symlink resolution.
A combination no loader can express is refused with a message in bwa_last_error, never narrowed
to one of the two: BWA_LOAD_AMBIX | BWA_LOAD_FUMA (one file is in one format) and
BWA_LOAD_STREAM with either of them (streaming is mono only). Unknown bits are refused too.
bwa_sound_release drops one reference. The last release unloads through the same retire-ack path
bwa_unload_sound uses, so releasing an asset a voice is still playing is safe.
Do not mix the tiers on one handle. bwa_unload_sound on an acquired handle is refused: it is
a no-op plus an error string, because freeing the buffer would pull the asset out from under the
other holders and leave the cache pointing at a recycled slot. bwa_sound_release on a handle the
cache does not own is refused the same way. Release what you acquired, unload what you loaded.
Both tiers are control thread only, and neither adds anything to the audio thread. The cache allocates, does the file I/O, and hands the core the same asset the explicit loaders hand it.
bwa_sound bwa_sound_find(bwa_engine* e, const char* path, uint32_t flags);Returns the handle for a key the cache already holds, or 0. It never loads, never touches the disk, and never takes a reference.
Use this to answer a question about a path rather than to get an asset: how long is this clip,
how many channels does it have, is it in memory yet, which handle does this file have so I can
release it. Do not use bwa_sound_acquire for those. A miss there loads the file, which turns a
metadata question into a hidden decode, and it loads mono, so an ambisonic bed would answer 1
channel forever.
A still-loading async entry answers with its handle, because it is resident and playable; ask
bwa_sound_is_ready about the data itself. An entry whose load failed answers 0, because it holds
nothing to answer about.
The handle is borrowed. bwa_sound_find took no reference, so the asset stays alive only as
long as the references you already hold. Read metadata from it; do not release against it.
Failure is deliberately not cached on the synchronous path: a bwa_sound_acquire that fails
records nothing, so the next call retries the file. That suits a file that appears late, and it
means a client that calls in a loop should remember its own failures rather than re-asking every
frame. The async path differs, because there the handle already escaped: a failed decode is sticky
and reports at bwa_sound_is_ready until you release it.
bwa_sound bwa_sound_acquire_async(bwa_engine* e, const char* path, uint32_t flags);
bool bwa_sound_is_ready(bwa_engine* e, bwa_sound snd);This is for content that appears mid-session. The CAVE's normal path is load-time and
synchronous, so reach for bwa_sound_acquire first.
bwa_sound_acquire_async returns a usable handle straight away and decodes on the engine's loader
thread. There is one such thread per engine. It starts on the first async acquire, so a session
that never loads asynchronously never pays for it, and bwa_destroy joins it.
The handle is playable at once. Bind it to a source and the source stays silent until the data lands, then starts playing from the top of the asset on the block it arrives. No frames are skipped and nothing clicks. That hold lives on the control thread: the audio thread is only ever handed a finished asset, so it neither allocates nor branches for a not-ready sound.
Two calls refuse a not-ready handle instead of holding it, and say so through bwa_last_error:
bwa_play_oneshot, because a oneshot owns no handle you could start later.bwa_source_queue, because a queue entry resolves to the asset at bind time.
BWA_LOAD_STREAM loads synchronously here. A stream open is cheap and streaming already decodes
off the control thread, so there is nothing to defer.
The point-source and bed split still applies while the decode is in flight. A handle acquired
with BWA_LOAD_AMBIX or BWA_LOAD_FUMA is a bed, and only bwa_bed_play takes it. Anything else
is a point source, and only bwa_source_play, bwa_source_play_at, and bwa_source_play_loop
take it. The wrong call is refused where you made it, with the reason in bwa_last_error, even
though the handle still reports 0 channels at that moment. The flags fixed the kind at acquire
time, so the engine does not have to wait for the decode to answer.
The refusal is what you should see. As a backstop, a held play whose asset lands as the other kind
is dropped rather than bound, and the bwa_commit that dropped it reports so through
bwa_last_error. Binding it instead would render your point source as a soundfield, because the
mixer dispatches on the asset's channel count, and your spread, directivity, and panner settings
would quietly do nothing.
bwa_sound_is_ready reports whether the data landed. It returns true for anything acquired
synchronously and false for a handle the cache does not own. Calling it also adopts finished
loads, and so does bwa_commit, so a client that commits every frame needs nothing else.
A decode that fails never becomes ready. The handle stays valid so it can never be recycled
under you, bwa_sound_is_ready keeps returning false, and the decoder's reason is in
bwa_last_error at that call. Release the handle; acquiring the same key again then retries the
load.
Releasing a still-loading handle is safe and cancels it. The reservation is dropped immediately and the worker's result is discarded when it arrives.
A finished decode is adopted only at a pump point (bwa_sound_acquire, bwa_sound_is_ready,
bwa_sound_find, bwa_sound_release, bwa_commit), and bwa_source_play is not one. So a play
issued straight after bwa_sound_acquire_async, with nothing pumping in between, is always held;
bwa_sound_get_channels reads 0 while it is, and does not pump.
Ownership. The loader thread allocates the PCM and owns it until it publishes the result. From the moment the control thread picks that result up, the control thread owns the buffer, hands it to the sound table, and frees it later through the retire-ack. The audio thread reaches it only through the ordinary play command, which is the same release the synchronous loaders have always used. See concurrency.md.
bwa_source bwa_source_create(bwa_engine* e); // handle returned synchronously
void bwa_source_destroy(bwa_engine* e, bwa_source s);
void bwa_source_set_priority(bwa_engine* e, bwa_source s, int priority); // 0 = expendable .. 255 = protected (default 128)
void bwa_source_set_pos (bwa_engine* e, bwa_source s, float x, float y, float z); // ROOM space, RH
void bwa_source_set_gain(bwa_engine* e, bwa_source s, float linear);
void bwa_source_fade_to (bwa_engine* e, bwa_source s, float gain, float seconds); // engine-side timed fade
void bwa_source_fade_out(bwa_engine* e, bwa_source s, float seconds); // fade to 0, then stop
void bwa_source_set_group(bwa_engine* e, bwa_source s, uint32_t group); // mix group 0..7 (default 0)
void bwa_group_set_gain (bwa_engine* e, uint32_t group, float linear); // scales every member (ramped)
void bwa_group_set_paused(bwa_engine* e, uint32_t group, bool paused); // pause a whole category
void bwa_group_stop (bwa_engine* e, uint32_t group); // stop a whole category (click-free)
void bwa_stop_all (bwa_engine* e); // stop every voice (scene transition)
void bwa_source_set_pitch(bwa_engine* e, bwa_source s, float rate); // playback rate [0.25, 4]; glided
void bwa_source_play (bwa_engine* e, bwa_source s, bwa_sound snd, bool loop);
void bwa_source_play_at(bwa_engine* e, bwa_source s, bwa_sound snd, bool loop, uint64_t start_sample); // sample-accurate
void bwa_source_play_loop(bwa_engine* e, bwa_source s, bwa_sound snd, uint64_t loop_beg, uint64_t loop_end); // intro->loop
void bwa_source_stop (bwa_engine* e, bwa_source s);
void bwa_source_stop_at(bwa_engine* e, bwa_source s, uint64_t stop_sample); // click-free stop on the dsp clock
void bwa_source_queue(bwa_engine* e, bwa_source s, bwa_sound snd, bool loop); // chain: play gaplessly after the current sound
void bwa_source_clear_queue(bwa_engine* e, bwa_source s); // drop the pending chain
void bwa_source_set_paused(bwa_engine* e, bwa_source s, bool paused); // ramped; playhead freezes
void bwa_source_seek (bwa_engine* e, bwa_source s, uint64_t frame); // click-free jump (in-memory)
void bwa_source_set_region(bwa_engine* e, bwa_source s, uint64_t start_frame, uint64_t end_frame); // play region
bool bwa_source_is_playing(bwa_engine* e, bwa_source s); // control-thread poll; see below
uint64_t bwa_source_get_playhead_frames(bwa_engine* e, bwa_source s); // CONTENT playhead, engine-rate frames
bool bwa_play_oneshot(bwa_engine* e, bwa_sound snd, float x, float y, float z, float gain); // false = droppedThe model: a source drives at most one voice. Play on an already-playing source restarts it
(a new sound replaces the old, un-paused at frame 0); the same bwa_sound can play on any number
of sources at once. And mind the near-namesakes: bwa_source_set_pos is the spatial position,
bwa_source_get_playhead_frames the content position: unrelated readouts.
Starts are click-free. Every play / play_at / play_loop ramps the per-channel gains up
from silence over the first block (~5 ms), the mirror of the one-block fade stop / stop_at use
to ramp down. So re-triggering a source whose asset doesn't begin near a zero crossing never pops.
The one hard cut left is bwa_source_destroy on a still-playing source: that's teardown, so stop
(or fade) first if the tail is audible.
The voice pool steals, it doesn't fail. The pool is fixed-size. When it's full,
bwa_source_create stops the lowest-priority active source to make room (255 = protected, never
stolen): an overloaded scene drops its least important sound instead of refusing the new one. Set
music and critical SFX high. The steal is click-free: the stolen voice fades out over one block on
its own slot while the new source starts immediately on a small reserve of spare slots.
Scheduled starts are sample-accurate. bwa_source_play_at begins output exactly when the
engine's dsp clock reaches start_sample: silent until then, then starting at the precise in-block
sample. Read "now" from bwa_get_dsp_time_frames (device sample position, monotonic) and add a delay:
bwa_get_dsp_time_frames(e) + sample_rate/2 plays half a second out. 0 means play immediately (same as
bwa_source_play); a start already in the past plays immediately, best-effort.
Loop a sub-region for intro→loop content. bwa_source_play_loop plays from the start but
wraps at loop_end back to loop_beg instead of the clip end: the intro [0, loop_beg) plays
once, the body [loop_beg, loop_end) loops forever. Frames are engine-rate. loop_end 0 means
the clip end, so play_loop(.., 0, 0) is just play(.., true); out-of-range bounds or
loop_beg >= loop_end fall back to whole-clip looping. In-memory sounds only; a streamed source
loops its whole file. The seam is a hard wrap with no crossfade, so author the loop points on
matched endpoints.
Scheduled stops can't pop. bwa_source_stop_at fires the click-free stop when the dsp clock
reaches stop_sample: a one-block fade (the same path bwa_source_stop takes), deliberately not
a hard cut at the exact sample, so silence lands within ~one block (~5 ms at 256/48k). Same time
base as play_at; a stop_sample in the past stops now; a later play / play_at /
play_loop clears a pending stop. Push sources don't take a scheduled stop; use
bwa_source_stop or bwa_source_push_end.
Chaining is gapless. bwa_source_queue plays a sound the instant the current one ends, with
no silence at the seam (the mixer swaps mid-block). Queue several for a sequence (A→B→C…); a
queued sound with loop = true is the terminal looping item, so the two-file intro→loop is
play(intro, false) then queue(body, true). Up to seven pending (further queues drop). Queue
after the play: play / play_at / play_loop restart the source and clear the queue.
Nothing chains after a looping current sound (it never ends) or a stopping one. In-memory
mono on both ends: a bed/stream/push snd is rejected (bwa_last_error), and a queue behind a
streamed current sound is ignored. The playhead restarts at each chained item and the source
reads as playing throughout; bwa_source_clear_queue drops the pending chain. To know which
item is playing, watch the playhead reset across the seam; there's no separate event.
// clock / scheduling - the time base for bwa_source_play_at:
uint64_t bwa_get_dsp_time_frames(bwa_engine* e); // current dsp-sample clock (device-anchored, monotonic)
bool bwa_get_clock(bwa_engine* e, uint64_t* dsp_sample, uint64_t* host_time_ns); // device (sample, host-time) pair
uint64_t bwa_host_time_ns(void); // that host clock, read directly (no engine)
uint32_t bwa_get_output_latency_frames(bwa_engine* e); // device render->DAC latency, frames (0 = unknown)
bool bwa_get_clock_model(bwa_engine* e, bwa_clock_model* out); // fitted device-vs-host drift (ppm + its sigma)Syncing with graphics. There are two cases.
- Events on the audio timeline (a cue in a track you scheduled) never need wall time. Keep the
start_sampleyou passed toplay_at, and fire the visual whenbwa_get_dsp_time_framescrossesstart + cue. Or pollbwa_source_get_playhead_frames. - Events on the graphics side need the wall→dsp mapping. That is
bwa_get_clock: the (output sample position, host time) pair the audio stack stamps inside each block callback. On ASIO it is theASIOGetSamplePositionpair; on the null sink it is the platform's monotonic clock.
The graphics-side recipe:
- Map with the pair.
dsp_at(T) = sample + (T_ns − host_time_ns) · rate / 1e9. The pair is captured in the callback, so this carries none of the block-plus-scheduling jitter you get from pairingbwa_get_dsp_time_frameswith your own clock read. - Measure the epoch offset.
host_time_nssits on a backend-defined epoch.bwa_host_time_nsreads that same clock with no engine handle, so you measure the offset to your own clock instead of estimating it:a = my_clock(); h = bwa_host_time_ns(); b = my_clock();givesoffset = h - (a+b)/2, with the error bounded by(b-a)/2. - Subtract the output latency. The dsp clock stamps when a block is rendered, not heard.
bwa_get_output_latency_framesis the device's render→DAC delay in frames (ASIOGetLatencies; the Digiface includes its Dante buffering), so sound scheduled for dsp time T reaches the room atT + latency. Subtract that and your measured display delay, and one constant aligns the whole AV chain.
"Land a sound on a visual event" has the whole recipe, the per-platform clock table, and the
decaying-max estimator that remains the fallback (the Unity binding's
Engine.DspTimeFramesAt/RealtimeAt use it).
Clock details:
bwa_get_clockreturns false with the outputs untouched until a host-stamped block has rendered, that is, beforebwa_start.- On ASIO the host time is on QPC whatever clock the driver's
systemTimeuses. The sink takes the driver's stamp only when the stamps show it is on QPC, and reads QPC at callback entry otherwise (backends.md: ASIO's systemTime). - The manual sink is the deliberate exception. It stamps a nominal time derived from the
sample position (
sample / rate), so a fixed call sequence renders bit-identically. Treat that pair as a sample-accurate fiction: exact for arithmetic, meaningless as wall time. Its first block carries nominal time 0 (read as "unstamped"), so the manual clock goes valid on block 2.
Long shows drift. The pair is exact at the instant it was stamped, but the device crystal and
the host clock are different oscillators: 10 ppm is 36 ms an hour, a spec-worst ±50 ppm part
~180 ms. Re-anchoring off bwa_get_clock every frame makes that vanish, because you never
integrate the error. The rule that follows: schedule far-out events in dsp samples, not by
predicting a future wall time. Take t0 = bwa_get_dsp_time_frames once at show start and place every
cue at t0 + cue × rate; now there is one clock in the system and drift is impossible rather
than corrected.
bwa_get_clock_model is for when you can't do that: something else owns the timeline, you want a
minutes-long extrapolation to hold, or you want the drift on the rig log. It fits the slope by
exponentially weighted least squares over the same per-block stamps (~2 minute window, a few
dozen flops a block) and reports ppm with its own ppm_sigma; use rate_hz in place of the
nominal rate in dsp_at(T) and long extrapolations stop walking off. It returns false until the
fit has ~1 s of stamps, and reseeds (going quiet ~1 s) after a restart re-bases the sample
position. Two honest caveats: read ppm_sigma as a lower bound (it assumes independent stamp
noise; real jitter is correlated), and jitter_ns reports stamp quality, so an ASIO driver whose
systemTime is absent or not on QPC reads worse from QPC dispatch noise. The manual sink fits ppm = 0 exactly;
that is true of the fiction, not of any hardware.
Correcting drift is a different problem from measuring it, and the engine deliberately doesn't do
it. If audio must follow an external master indefinitely, the clean lever is a push source:
run your own resampler with bwa_source_push_space as the error signal. The best fix isn't in
software at all: clock the display machines from the same reference as the audio device (the
Digiface is a Dante endpoint, already disciplined by a PTP grandmaster) and the two clocks become
one.
Completion is an event. bwa_poll_ended(e, out, cap, dropped_out) drains the handles whose
voices ended since the last call, oldest first, and returns how many it wrote. It fills from the same
drain bwa_commit runs, so poll after commit.
bwa_commit(e);
bwa_source done[32];
uint32_t n = bwa_poll_ended(e, done, 32, NULL);
for (uint32_t i = 0; i < n; ++i) on_finished(done[i]);Handles come back as you knew them, so comparing against a stored handle works. A plain source's
handle stays valid and re-playing it is normal. Oneshots are never reported, because the engine never
gave you a handle for one, and a stolen voice is not reported either: the engine took the slot, the sound
did not finish. A completion superseded by a re-play of the same handle is dropped rather than
reported, so you cannot free or re-trigger the wrong play. Unpolled events are bounded and drop
oldest; pass dropped_out to tell "nothing finished" from "I did not poll often enough".
A loop wrap is an event too. A looping voice never ends, so bwa_poll_ended reports it
exactly never. bwa_poll_looped(e, out, cap, dropped_out) is its sibling for the other boundary:
it drains the handles whose voices WRAPPED at a loop point since the last call, oldest first, from
the same drain bwa_commit runs. Pace experimental trials, cue a visual, or count repetitions
with it.
bwa_commit(e);
bwa_source wrapped[32];
uint32_t n = bwa_poll_looped(e, wrapped, 32, NULL);
for (uint32_t i = 0; i < n; ++i) on_loop_boundary(wrapped[i]);Both drains are ENGINE-WIDE and DESTRUCTIVE, so give each exactly one caller. A second caller
consumes events belonging to somebody else's sources and those are simply never seen. In a binding
that means the object owning the frame owns the drain and dispatches from it, which is what
Unity's Engine and Godot's BwaEngine do. Drain after bwa_commit, which is the pass that
fills both rings.
Everything else matches bwa_poll_ended: handles come back as you knew them, one-shots are never
reported, a wrap superseded by a re-play of the same handle is dropped, and unpolled events are
bounded and drop oldest with their own dropped_out total. Two things are specific to it. It
reports ONE ENTRY PER WRAP, not per block and not per poll, so a loop shorter than an audio block
gives you several entries from one block rather than one. And the wrap point is the loop region's
end when a region is set (bwa_source_play_loop or bwa_source_set_region), else the asset end.
In-memory and bed voices only: a streamed source loops inside the stream, which this never sees.
One case is deliberately not counted span by span. Shrink a region under a live cursor and the cursor catches up in a single step, reporting one wrap however many spans it skipped. Nothing played through those spans, so counting them would report a history that never happened.
Completion is also a poll, and that path is weaker. bwa_source_is_playing is a latest-wins
readback (like bwa_get_listener_pose): the audio thread republishes each source's playing state
every block, gated on the handle's generation and on a play sequence, so a re-play on a handle whose
voice already ended reads true immediately rather than lying until the next block. It reads true
while a sound plays; false once a non-loop sound finishes, after stop, or for a stale handle.
A play HELD on a still-decoding bwa_sound_acquire_async asset is the one case that reads false
after you issued it: nothing is enqueued yet, so the readback has nothing to sequence against.
Watch bwa_sound_is_ready for that. What it cannot do is see a sound shorter than your poll
interval, which no amount of sequencing fixes. Use it for "is this still going", not for "did it
finish".
The playhead is a poll too. bwa_source_get_playhead_frames rides the same per-block
republish: the voice's content position in engine-rate frames. It is correct exactly where
deriving a playhead from bwa_get_dsp_time_frames breaks: it freezes under pause, lands where a seek
lands, follows a pitched voice at its actual rate, wraps with a loop, and for stream/push
sources counts frames actually consumed (an underrun slips it, exactly like what you hear).
A finished non-loop voice keeps reporting its final position; an idle voice, a still-held
scheduled play, or a stale handle reads 0. Block-granular and one block behind a just-issued
play/seek; for tighter scheduling, stay on bwa_get_dsp_time_frames arithmetic.
Pause and seek are click-free.
bwa_source_set_pausedgates the voice with a one-block ramp (~5 ms) and freezes the playhead once silent, so resume continues exactly where pause landed. Works for in-memory, streamed, and bed sounds. A paused voice still reads as playing: it hasn't ended.bwa_source_seekjumps the content position (engine-rate frames). On a running voice: ramp out, jump, ramp back in (~10 ms end to end). On a paused voice the jump is immediate and it stays paused. Past-the-end seeks wrap for loops and end one-shots. Issued after a play still held on abwa_sound_acquire_asyncdecode, the seek rides with that play and lands with it.- Streamed sounds ignore seeks: the stream ring can't jump.
bwa_source_playalways restarts un-paused at frame 0.
The play region bounds the content, seek moves inside it. bwa_source_set_region(e, s, start_frame, end_frame) limits the voice to [start_frame, end_frame) in engine-rate frames.
end_frame 0 means the asset end, which is the default state. What happens at the out-point
depends on the play, and that is the whole design:
- A non-looping voice ENDS there, exactly as it would at the asset end. It reports through
bwa_poll_ended, and a queued sound (bwa_source_queue) chains as it would at any end. - A looping voice wraps back to
start_frameand reports throughbwa_poll_looped.
So a loop region and a truncated one-shot are one call. Use it to cut a stimulus down to the window you want, or to loop a body without re-cutting the file.
Set the region AFTER the play. The bounds resolve against the bound asset, and any play /
play_at / play_loop RESETS the region, since a play restarts the source. bwa_source_play_loop
is the same state set at play time. That order also holds while the asset is still decoding under
bwa_sound_acquire_async: the region rides with the held play and lands with it.
The region does not move the playhead. A region set mid-play takes effect at the next boundary: a
cursor already past end_frame ends or wraps on the next block, and one before start_frame keeps
playing until end_frame. That catch-up counts as ONE wrap in bwa_poll_looped however far past
end_frame the cursor was. A seek lands INSIDE the region: below start_frame it clamps up to it,
at or past end_frame it wraps (looping) or ends (one-shot). With the default region that is the
behavior seek always had. An end_frame past the asset end means the asset end, and a
start_frame at or past the asset end means the WHOLE clip rather than an empty region;
end_frame <= start_frame with end_frame != 0 is refused and sets bwa_last_error. In-memory
and bed sounds only, the same scope as bwa_source_seek; streamed and push sources ignore it.
Beds get the whole set under their own prefix: bwa_bed_set_region and bwa_bed_play_loop.
Fades are engine-side. bwa_source_fade_to glides the gain over seconds on the audio thread
(no per-frame scripting; seconds <= 0 sets immediately). A later set_gain or fade replaces the
fade in flight. bwa_source_fade_out is the one-call "fade to silence, then stop"; it lands on the
same click-free stop path bwa_source_stop uses.
Mix groups (ids 0..BWA_GROUPS-1, that is, 0..7) are category-level control: a group's gain
multiplies into every member's gain solve (ramped like any solve). Pausing a group ramps its
members out and freezes their playheads exactly like per-voice pause: duck the SFX and keep the
dialog, silence the ambience category for a cutscene. Sources start in group 0; group state
persists across play. Out of range: set_group falls back to group 0, group gain/pause/stop calls
are ignored.
Stopping a scene. bwa_group_stop stops every member of one group. bwa_stop_all stops every
voice in the engine, whatever its group. Use them for a scene transition instead of looping over
your own handles. Both take the same click-free path bwa_source_stop takes: each voice fades to
silence over one block, around 5 ms, then ends, so a whole-scene stop cannot pop. Both also drop
each stopped voice's pending chain (bwa_source_queue), so nothing you queued starts after the
transition. Beds are voices, so bwa_bed_* stops with everything else. A stopped source handle
stays valid and re-playable. Only bwa_play_oneshot's transient voices are recycled. Two things
they do not do: a push source's voice stops like any other but its feed ring stays open (end it
with bwa_source_push_end or bwa_source_stop), and neither call touches group gains, group
pause, the global pause, or the master gain. A stop stops sound, it does not reset the mixer. Both
also drop the plays still waiting on an async decode (bwa_sound_acquire_async), which would
otherwise start by themselves once their data lands. bwa_stop_all drops every one.
bwa_group_stop drops the ones issued on sources in that group. A play you issued and then
stopped never sounds, whichever stop you used. A still-decoding play has not reached a voice yet,
so the group it belongs to is the one its source was in when the stop ran.
Pitch (bwa_source_set_pitch, 1 = native, clamped [0.25, 4]) resamples in-memory sounds
with a fractional playback cursor (linear interpolation; the cursor stays integer + fraction, so a
voice running for hours never loses precision). Rate changes glide across a block (a change
bends the pitch rather than stepping it) and compose with Doppler (which resamples via propagation
delay on top). Streamed sounds ignore it (the stream ring is sequential); beds are unaffected. Use
it for one-shot variation, slow-mo, engines.
Positions are in room space (see Coordinates and units).
bwa_play_oneshot is the fire-and-forget path: it allocates a transient voice internally and
recycles it on end, so the caller holds no handle. Unlike bwa_source_create it never
steals: with the voice pool (or command ring) momentarily full the oneshot is dropped, so
oneshot spam can't evict your named sources. It returns whether it was accepted, the only
way to tell "played" from "never loaded" from "dropped under load"; false sets bwa_last_error
to which of the three it was. If you need to track the voice afterwards, that is what
bwa_source_create is for.
if (!bwa_play_oneshot(e, impact, x, y, z, 1.f))
log("impact dropped: %s", bwa_last_error(e));bwa_source bwa_source_create_push(bwa_engine* e); // 0 = failure (see bwa_last_error)
uint32_t bwa_source_push(bwa_engine* e, bwa_source s, const float* frames, uint32_t n); // frames accepted
uint32_t bwa_source_push_space(bwa_engine* e, bwa_source s); // frames a push would accept right now
void bwa_source_push_end(bwa_engine* e, bwa_source s); // end-of-data: ends once drainedEngine-generated audio without a file: bwa_source_create_push returns a source whose voice
plays PCM you push: mono float frames at the engine rate, through a per-source ring (65536
frames, ~1.37 s at 48 kHz). It is a normal source in every other way (position, gain, spread,
occlusion, Doppler, groups, fades, pause). Use it for synthesis, network audio, or bridging
another engine's output.
Three rules cover the model:
- The stream clock is data-driven. The voice consumes from create: silence until your first
push, and an underrun renders silence without losing your place: output resumes at the
next pushed sample. It slips, it never drops. Stay a frame's worth ahead;
bwa_source_pushreturns the count accepted (pace withbwa_source_push_space). - Push from the one control thread: the ring is SPSC. The engine writes non-finite samples as 0 and clamps samples beyond ±32 (+30 dBFS), so nothing hands NaN or an overflowing value to the audio thread. File loads sanitize the same way.
- Ending is one-way.
bwa_source_push_endmarks end-of-data: the voice ends once the ring drains, and further pushes are refused. A push source is not restartable: create a new one.bwa_source_stop/bwa_source_fade_outend it the same way (the unconsumed remainder is dropped); usebwa_source_set_pausedto silence one temporarily.bwa_source_destroyreleases the ring (safe while playing; retire-acked like any sound).
bwa_source_play / seek / pitch don't apply: a push source plays what you push (play is
rejected with an error). The reverse mix-up is reported too: push / push_space / push_end
on a live non-push source return 0 / do nothing with an error, so a wrong handle never
masquerades as ring backpressure (a stale handle stays the usual silent no-op). A full pool can
steal a push source like any voice; protect an important one with priority 255.
void bwa_set_master_gain(bwa_engine* e, float linear); // one ramped scalar over the whole mix; live
void bwa_set_paused(bwa_engine* e, bool paused); // pause EVERYTHING (ramped, playheads freeze); livebwa_set_master_gain is the volume knob / scene fade: it scales everything mixed (voices, beds,
the reverb/pathing taps, and under BWA_PROFILE_BINAURAL the direct-binaural field, which never
touches the speaker bus) before the per-speaker align stage (so trims and the raw channel-test
signal stay calibrated) and before the limiter (which still guards the sum). Ramped per block;
dragging a slider never zippers. bwa_set_paused is app-focus /
menu pause: every voice gates out with the per-voice pause machinery (memory, streamed, and bed
alike), playheads freeze, resume continues exactly, and paused voices still read as playing.
bwa_sound bwa_load_ambix(bwa_engine* e, const char* path); // AmbiX (ACN/SN3D); 4/9/16 ch -> order 1/2/3
bwa_sound bwa_load_fuma (bwa_engine* e, const char* path); // legacy FuMa B-format; converted at load
bwa_bed bwa_bed_create (bwa_engine* e);
void bwa_bed_play (bwa_engine* e, bwa_bed b, bwa_sound snd, bool loop);
void bwa_bed_play_at (bwa_engine* e, bwa_bed b, bwa_sound snd, bool loop, // sample-accurate start
uint64_t start_sample);
void bwa_bed_play_loop(bwa_engine* e, bwa_bed b, bwa_sound snd, // intro -> loop region
uint64_t loop_beg, uint64_t loop_end);
void bwa_bed_set_gain(bwa_engine* e, bwa_bed b, float linear); // master gain, ramped
void bwa_bed_set_orientation(bwa_engine* e, bwa_bed b, // full 3-axis (yaw/pitch/roll);
float yaw_rad, float pitch_rad, float roll_rad); // glided, live
void bwa_bed_stop (bwa_engine* e, bwa_bed b);
void bwa_bed_destroy (bwa_engine* e, bwa_bed b);
// same voice machinery as the bwa_source_* calls of the same name - DELIBERATELY bed-named so bed
// code never mixes prefixes (semantics under "Sources"):
void bwa_bed_stop_at (bwa_engine* e, bwa_bed b, uint64_t stop_sample);
void bwa_bed_fade_to (bwa_engine* e, bwa_bed b, float gain, float seconds);
void bwa_bed_fade_out (bwa_engine* e, bwa_bed b, float seconds); // fade, then click-free stop
void bwa_bed_set_paused (bwa_engine* e, bwa_bed b, bool paused); // freeze/resume in place
void bwa_bed_seek (bwa_engine* e, bwa_bed b, uint64_t frame);
void bwa_bed_set_region (bwa_engine* e, bwa_bed b, // play region, content frames
uint64_t start_frame, uint64_t end_frame);
void bwa_bed_set_priority(bwa_engine* e, bwa_bed b, int priority); // beds share the voice pool -
void bwa_bed_set_group (bwa_engine* e, bwa_bed b, uint32_t group); // protect a music bed with 255
bool bwa_bed_is_playing (bwa_engine* e, bwa_bed b);
uint64_t bwa_bed_get_playhead_frames(bwa_engine* e, bwa_bed b); // content playhead, engine-rate framesbwa_bed_play_at and bwa_bed_play_loop are separate entry points rather than aliases, because
the asset check runs the other way round: they demand a multichannel asset where the
bwa_source_* forms refuse one. Everything else in the second block is the same call under the
bed prefix.
What has no bed alias, and why:
| not aliased | reason |
|---|---|
set_pos, set_spread, set_extent, set_size, set_proximity, set_attenuation_override, set_doppler, set_air_absorption |
a bed is world-locked and carries no position, so nothing distance- or direction-derived applies |
set_occlusion, set_occlusion_manual, set_directivity, set_orientation (the quaternion one) |
same reason. bwa_bed_set_orientation is a different call: it rotates the recorded soundfield, not a dipole axis |
set_reverb, set_reverb_send, set_reverb_distance, set_early_reflections, set_pathing |
the bed decode never writes the aux bus those sends read |
set_channel |
a bed decodes to all speakers by definition, and the gain solve returns before the route is consulted |
queue, clear_queue |
a bed does not chain. The queue is in-memory mono only, and the bed mix never pops it |
preset, create_desc, apply, get_desc |
bwa_source_desc is a point-source configuration |
set_pitch |
accepted, but a no-op on a bed (see bwa_source_set_pitch) |
bwa_poll_ended and bwa_poll_looped report bed handles like any other, so they have no
bed-named twin. They are engine-wide drains, not per-handle calls.
bwa_bed_set_orientation orients the recorded field in 3 axes: yaw to line a capture up with the
scene, pitch/roll to level a capture whose "front" wasn't upright. Positive yaw turns the
field from room +z toward +x; positive pitch tilts its front upward; positive
roll tilts its top toward the room's right (−x); applied roll → pitch → yaw. Yaw-only
stays on the exact closed-form phasor path (each degree's ±m pair rotates by m·yaw; exact at
every order); any pitch/roll runs a full SH rotation matrix (the Ivanic-Ruedenberg recursion),
rebuilt per block from the glided angles. Either way it glides at ~one turn per second
(click-free, live-safe) and applies before either bed renderer, so the matrix decode and the
parametric analysis see the same turned field.
A bed is a pre-encoded AmbiX soundfield (ACN ordering, SN3D normalization) decoded straight to the speakers. It is not panned and has no position; use it for diffuse, ambient content.
Beds are world-locked: the soundfield is fixed to the room. The physical speakers are world-fixed too, so the real acoustics handle a listener walking through the field, and the headphone renders' head-tracking applies downstream.
Load with bwa_load_ambix (full 3D sets only: 4/9/16 channels; mono and other counts are
rejected, because AmbiX has no mixed-order convention), then drive with the bwa_bed_* family:
no position, only a master gain. Legacy FuMa B-format (.amb and friends: WXYZ order, MaxN
normalization, the W −3 dB) loads with bwa_load_fuma; the conversion happens at load, so past
that call the asset is indistinguishable from an AmbiX load of the same field. bwa_load_fuma
also takes the .amb mixed-order sets, which the format identifies by channel count:
3 (1H: WXY), 5 (2H: WXYUV), 6 (2H1V: WXYZUV), 7 (3H: WXYUVPQ), 8 (3H1V: WXYZUVPQ) and 11
(3H2V: WXYZRSTUVPQ). Such a bed is stored in the ACN slots of its highest order with zeros in
the channels it lacks, so bwa_sound_get_channels reports 4, 9 or 16, and it remembers its set.
With an imported decoder (BWA_DECODE_LAYOUT) it decodes through the decoder designed for that
set, if the layout has one. Otherwise it decodes through the full-order matrix, and the first
such bwa_bed_play* sets a one-time notice in bwa_last_error (the play itself succeeds). Internally a bed is a voice playing a multichannel asset,
so handles and lifetime match bwa_source_*: fades, pause/seek, priority, and groups are the
same per-voice machinery re-exported under the bed prefix. The priority default (128) means a bed
can be stolen by a full-pool bwa_source_create; set 255 on a bed that must survive an SFX
overload. The decode is a static SN3D SH→speaker matrix (AllRAD, EPAD, or the layout's imported
decoder, per bwa_desc.bed_decoder; see below), rebuilt from the layout.
void bwa_set_max_re(bwa_engine* e, bool on); // ON by default; live A/B (crossfaded)
void bwa_set_max_re_split(bwa_engine* e, bool on); // off by default; live A/B; needs max_re onbwa_set_max_re puts max-rE weighting (Zotter and Frank's psychoacoustic decoder weights) on the
engine's SH→speaker decode: it tapers the higher ambisonic orders, which suppresses the decode's
sidelobes and lengthens the energy vector. You get better localization away from the sweet spot,
exactly the walking-listener case, at a slightly wider main lobe. The weights are
diffuse-energy-normalized per content order, so A and B stay level-fair. It reaches every consumer
of the engine's own decode: bed matrix rendering (the sampling fallback / AllRAD / EPAD) and the FDN reverb's
line render. It doesn't reach the point-source panners (DBAP/SPCAP/VBAP pan, they don't decode) or
phonon's own decodes (reflection bed, pathing, the HRTF monitor). ON by default: in the offline
bed sweep the taper won every axis under both decoders and both observer models. The rig trial
has not run yet. It has no effect on an imported decoder (BWA_DECODE_LAYOUT): that matrix is
final, its designer already chose the per-order gains, and a second taper would double-weight the
high orders. The same holds for the FDN's render under an imported decoder.
bwa_set_max_re_split is the band-split refinement (the literature-standard Gerzon
basic-LF/max-rE-HF decode): with it on, the taper acts only above a ~700 Hz crossover; below,
the unweighted decode stays, because the ear localizes LF by summed pressure (the velocity vector,
which the plain decode maximizes) and HF by energy. Same 700 Hz boundary as dual-band panning; a
per-bed one-pole splitter, crossfaded like the taper itself, so both toggles are click-free live
A/Bs. Bed matrix decodes only: the FDN's line render stays broadband either way (a diffuse tail
has no LF image to sharpen). Broadband versus split is a by-ear call for the rig. An imported
TWO-BAND decoder brings its own split instead (its LF and HF matrices at its own xover_hz), and
this toggle does not touch it either.
typedef enum { BWA_BED_MATRIX = 0, BWA_BED_PARAMETRIC = 1 } bwa_bed_renderer;
void bwa_set_bed_renderer(bwa_engine* e, bwa_bed_renderer renderer); // live A/B (each bed crossfades)Two renderers sit behind the same bed API:
- matrix (default): the static SH→speaker decode above (AllRAD or EPAD per
bwa_desc.bed_decoder). Cheap and robust, but an array of two dozen speakers is sparse for a matrix decode (directional content blurs) and the decode is world-locked around the array center. - parametric (
BWA_BED_PARAMETRIC): first-order DirAC-style rendering. Per band, the non-diffuse stream is re-panned through the engine's own listener-relative panner, so a recorded soundfield becomes walkable (correct directions and parallax off-center); the diffuse stream decodes through the matrix into the per-speaker decorrelators. Both streams are loudness-matched to the matrix decode; beds with fewer than 4 channels stay on the matrix; the switch crossfades per bed, a clean live A/B. Full treatment: spatialization.md.
Note: FLAC is the natural container for lossless multichannel beds. MP3 can't carry ambisonics.
void bwa_set_listener_pose(bwa_engine* e, float px,float py,float pz,
float qx,float qy,float qz,float qw);Every render uses position. Orientation (the quaternion) reaches the headphone renders only; the array render ignores it. With a tracker connected, do not call this: the engine samples the freshest OptiTrack pose at block time.
typedef struct {
const char* multicast; // NULL -> "239.255.42.99"
const char* server; // Motive host (handshake + rigid-body names); NULL -> multicast-only
const char* local_iface; // local interface IP to join on; NULL -> the one routing to `server`, else the OS default
uint16_t data_port; // 0 -> 1511
uint16_t command_port; // 0 -> 1510
int32_t rigid_body_id; // streaming ID; 0 -> first rigid body in the frame
const char* rigid_body_name; // by name instead of ID (needs `server`); NULL -> use the ID
int32_t version_major, version_minor; // used if the handshake does not answer; 0 -> read from the frames
uint32_t reserved[4];
} bwa_tracker_desc;
bwa_result bwa_tracker_connect(bwa_engine* e, const bwa_tracker_desc* desc); // may block
void bwa_tracker_disconnect(bwa_engine* e);Connect the engine to a NatNet (Motive) stream and it ingests the pose itself. It samples the
freshest head pose on the audio thread at block time (lower latency than pushing pose
through the command ring), and it overrides any bwa_set_listener_pose. This is a runtime call, like
every other NatNet client: connect before or after bwa_start, reconnect with new settings
(the replacement is glitch-free: the audio thread swaps pose sources between blocks), or
disconnect to fall back to the committed/pushed pose. It is lifecycle-class (opens a socket, may
block briefly), not a per-frame call. Zero fields take the defaults shown.
The engine resolves a rigid-body name to its streaming ID at connect through the model
definitions (a NAT_REQUEST_MODELDEF exchange); this needs server and NatNet 3.0 or later. A name that doesn't
resolve fails the connect with BWA_ERR_TRACKER and the reason in bwa_last_error. A failed
connect leaves the engine running on the committed/default listener: nothing tears down.
The tracker's lifetime is independent of bwa_start/bwa_stop (it survives a device restart);
bwa_destroy disconnects it. One invariant carries over from bwa_start: a layout carrying
static room_eq (fixed-listener room correction) refuses a tracker; recalibrate with
--room-eq-grid for tracked sessions (see calibration.md).
typedef enum {
BWA_TRACKER_DISCONNECTED = 0, // no tracker connected on this engine
BWA_TRACKER_NO_DATA = 1, // connected, but no frames arriving
BWA_TRACKER_NO_BODY = 2, // frames arriving, the followed body has no valid pose
BWA_TRACKER_LIVE = 3, // the followed body's pose is current
} bwa_tracker_state;
bwa_tracker_state bwa_tracker_status(bwa_engine* e); // control thread; never blocksA successful bwa_tracker_connect only means the socket opened. On multicast there is no handshake,
so the connect return tells you nothing about whether Motive is actually streaming or whether your
rigid body is in view. And if the stream drops mid-session, nothing tears the connection down.
bwa_tracker_status reports what the wire is doing right now, so you can drive a status light or a
dropout alarm. You
cannot get it from bwa_get_listener_pose: a dead tracker just holds the last pose, which reads the
same as a working one parked at that spot.
The two failure states are split because they need different fixes:
| State | Meaning | What to check |
|---|---|---|
DISCONNECTED |
No tracker on this engine (also for a NULL engine). |
You never called bwa_tracker_connect, or you disconnected. |
NO_DATA |
No FrameOfData packets recently. |
Motive is streaming, the network path, the data port, the multicast group. |
NO_BODY |
Frames arrive, but the followed body has no valid pose. | The rigid_body_id/rigid_body_name, or the body is occluded right now. |
LIVE |
The followed body's pose is arriving and fresh. | - |
"Recent" is a ~250 ms window, so a live stream at 100–360 Hz never trips it and a couple of dropped frames don't flap the status. It derives from local packet-arrival timing, not the pose stamps (those ride the server clock on NatNet 4.1+ and can't be compared to a local clock). The call never blocks and is cheap enough to poll every frame.
void bwa_set_pose_prediction(bwa_engine* e, float lead_s); // 0 = off (default); liveThe tracking chain (Motive's solve, the network hop, the audio block, the DAC) puts the rendered
pose 20–40 ms behind the head; at walking speed that is 3–6 cm of panning lag. With a lead set,
the engine extrapolates the tracked position lead_s seconds along a velocity estimated from the tracker's
own frame timestamps (smoothed over ~100 ms so Motive's frame-to-frame jitter doesn't shake the
image, speed-capped, and reset across drop-outs so a stale velocity never extrapolates). On
NatNet 4.1–4.5 those timestamps come off the wire: the server's camera clock (mid-exposure when
the command channel is available, the frame's software timestamp when version_major/version_minor
force the version over pure multicast), so bursty packet delivery doesn't shake the estimate and a camera-rate
change in Motive doesn't mis-scale it. Older streams (and streams newer than the parser's
certified suffix layout) fall back to stamping at packet arrival, so prediction keeps working
either way. If a future Motive outruns the parser, pin the server to a known syntax with NatNet's
unicast-only Bitstream command.
Set lead_s to your measured motion-to-ears latency (in seconds); too much lead overshoots on
direction changes, so start at the measured value, not above it (clamped at 0.2 s). The engine does
not predict orientation (it only feeds the headphone decodes). Internal tracking only (needs a
connected tracker).
#define BWA_EXTRA_LIS 3
void bwa_set_extra_listeners(bwa_engine* e, const float* xyz, uint32_t count); // up to BWA_EXTRA_LIS; 0 clearsA CAVE usually holds more than one person; single-listener panning is exact for the tracked head
and wrong for everyone else. Give the other occupants' positions here (count·3 floats, room
space; per-frame-safe, commit-gated like the pose). Every source's gains become the per-speaker
energy mean of the per-listener solves: the L2 barycenter of the individual renderings, so
each occupant hears an image biased toward their own solve instead of one exact and N wrong.
Constant-power; works with every panner (each extra keeps its own SPCAP/VBAP cache, so the solves
stay cache-warm). The primary listener remains bwa_set_listener_pose/tracking and still drives
spread direction, Doppler, air absorption, the reverb-send distance, and the headphone renders
(BWA_PROFILE_BINAURAL ignores extras entirely: headphones are one head by construction).
count = 0 restores single-listener panning. Cost: one extra point solve per listener per dirty
voice (block-rate, negligible).
void bwa_get_listener_pose(bwa_engine* e, float p[3], float q[4]);Returns the pose the engine is currently rendering with: the committed pose, or, with a tracker
connected, the freshest tracked pose. Safe to poll from the control thread (published by
the audio thread through a seqlock). For visuals, logging, or bringing up the tracker (see the
bwa_track_monitor example). Returns identity until the first audio block / tracked frame.
void bwa_commit(bwa_engine* e);Call once per frame after pushing all source and listener updates. It promotes this frame's position/pose to a coherent snapshot (so the audio thread never mixes a moved listener against a not-yet-moved source) and drains the event ring.
typedef enum { BWA_MAT_GENERIC = 0, BWA_MAT_BRICK, BWA_MAT_CONCRETE, BWA_MAT_CERAMIC,
BWA_MAT_GRAVEL, BWA_MAT_CARPET, BWA_MAT_GLASS, BWA_MAT_PLASTER,
BWA_MAT_WOOD, BWA_MAT_METAL, BWA_MAT_ROCK } bwa_material_type;
bwa_material bwa_material_preset(bwa_engine* e, bwa_material_type preset); // GENERIC = token 0
bwa_material bwa_material_define(bwa_engine* e, const float absorption[3], float scattering,
const float transmission[3]);
void bwa_material_release(bwa_engine* e, bwa_material token); // free the slot for reuse
void bwa_scene_set_mesh_mat(bwa_engine* e, const float* verts, int nverts, const int* tris, int ntris,
const bwa_material* tri_material); // one token per triangle (STATIC world)
void bwa_scene_set_box (bwa_engine* e, float w, float h, float d, const bwa_material faces[6]); // -x,+x,-y,+y,-z,+z
void bwa_scene_set_ism_room(bwa_engine* e, float w, float h, float d, const bwa_material faces[6]); // the shoebox ONLY
bool bwa_box_mesh(float w, float h, float d, const bwa_material faces[6],
float* out_verts, int* out_tris, bwa_material* out_tri_material); // pure: 8 verts, 12 tris
// Dynamic (movable) occluders/reflectors - one instanced sub-scene per mover, placed by a rigid transform.
int bwa_scene_add_dynamic_mesh(bwa_engine* e, const float* verts, int nverts, const int* tris, int ntris,
bwa_material material); // -> handle >= 0, or -1
void bwa_scene_set_dynamic_transform(bwa_engine* e, int handle, float x, float y, float z,
float qx, float qy, float qz, float qw); // room-space pos + quat
void bwa_scene_remove_dynamic_mesh(bwa_engine* e, int handle);A bwa_material is an opaque, engine-scoped token: a small index, where 0 is always the
built-in GENERIC default. Mint one from a preset (Steam Audio's published coefficients;
BWA_MAT_GENERIC returns 0 without minting) or from custom 3-band coefficients (clamped to
[0,1], NaN-sanitized). Both paths return the same kind of token from one fixed 64-entry
table; on overflow (or an out-of-range enum) the mint returns 0 and sets bwa_last_error.
Tokens are not generation-checked: they stay valid until released, and per-triangle indices
out of range clamp to the default.
Most apps mint once and never release: the table is a palette, not a per-object allocation.
For an app that churns many distinct materials, bwa_material_release(token) frees a slot for
reuse. It's caller-managed, like free(): only release a token no live mesh or source still
references. Already-set meshes copied their coefficients at set time, so they're unaffected; a
future bwa_scene_set_mesh_mat with a released token gets whatever the slot was reused for.
Token 0 can't be released.
Geometry rules:
- Room space, RH meters, CCW triangles.
bwa_scene_set_boxbuilds a floor-based shoebox with inward-facing normals: x/z centered on the origin, y from 0 (the floor) up toh; the listener stands inside. - The outdoor degenerate:
bwa_scene_set_ground(e, y, mat, pressure_release). One horizontal mirror plane at heightyinstead of a box: the ground bounce, the dominant early reflection when there is no room. Same dual capture as the box (ISM always, a large ground quad for the ray tracer with SDK); replaces any prior box (one room at a time); live-safe like the box.pressure_releaseflips the reflection's polarity: set it when the "ground" is a water surface and the listener is under it. - Pressure-release faces:
bwa_scene_set_pressure_release(e, face_mask). Flags box faces (bit f = face f, the-x,+x,-y,+y,-z,+zorder) whose image-source reflection should NEGATE: the physics of reflecting off a much softer medium. The flagship case is a virtual underwater room whose ceiling is the surface (1u << 3): the inverted image interferes destructively with the direct sound near the boundary, the Lloyd's-mirror comb. ISM only (polarity is a specular concept); occlusion and the reverb bed keep the face's material. Call after_set_boxor_set_ground; a later room call resets every face. - One scene, two consumers. The same per-triangle materials feed both occlusion (per-band
transmission) and the reflection bed (absorption/scattering): one shared
IPLScene. bwa_scene_set_boxreplaces the static mesh. It is abwa_scene_set_mesh_matcall plus the ISM capture. Calling_set_boxand then_set_mesh_mat(room first, then the pillar standing in it) drops the walls half-way: the image-source shoebox is separate state and survives, so early reflections keep bouncing off a room that occlusion and the reflection bed can no longer see. To have both, usebwa_scene_set_ism_roomandbwa_box_mesh; see Composing a scene.- Static versus dynamic.
bwa_scene_set_mesh_mat/_set_boxset the STATIC world: committed once, BVH built once. For things that MOVE,bwa_scene_add_dynamic_meshregisters a rigid instance (its own sub-scene) that you reposition withbwa_scene_set_dynamic_transform(room-space position- orientation quaternion), a cheap top-level BVH refit, not a geometry rebuild, so it's the
per-frame path. Geometry is in the mover's LOCAL space; one material token covers all its triangles;
the movable table holds 32 instances.
addreturns a handle (or-1: no SDK / bad geometry / full).
- orientation quaternion), a cheap top-level BVH refit, not a geometry rebuild, so it's the
per-frame path. Geometry is in the mover's LOCAL space; one material token covers all its triangles;
the movable table holds 32 instances.
- Runtime-safe. Both the static swap and dynamic-mesh moves are safe to call at any time; a
scene commit never races a ray trace. Staged geometry is flushed synchronously when the reflection bed or pathing is created, so
bwa_scene_set_boxfollowed immediately bybwa_startbakes the room you just set. Caveat: baked reflections/pathing don't track a runtime change (the bake froze the geometry; see materials.md); real-time reflections and occlusion do. Replacing the whole static mesh at runtime rebuilds the entire BVH; prefer dynamic meshes for movers. - No SDK, no-op. Everything here is a documented no-op without the
BWA_HAVE_STEAMAUDIObuild, except token minting (plain table state), the purebwa_box_mesh, and the image-source room calls:bwa_scene_set_boxandbwa_scene_set_groundALWAYS capture the room for the early reflections, andbwa_scene_set_ism_roomandbwa_scene_set_pressure_releasetouch nothing else.bwa_scene_add_dynamic_meshreturns-1without the backend. - The room calls are live-safe.
bwa_scene_set_box/_set_ground/_set_pressure_releasepublish the ISM room to the audio thread through a seqlock, and each opted-in source re-solves its images next block: gains ramp, delays glide, so a mid-scene room change (the submerge transition) bends the reflections instead of clicking. With the SDK a live box/ground also pays the static-mesh BVH rebuild.
bwa_scene_set_box does two jobs: it captures the shoebox for the image-source early reflections
(which works with or without the Steam Audio build) and it sets the ray-traced static mesh to that
box. That is what you want when the box is the scene. It replaces the static mesh, so on its
own it cannot layer the box under your occluders.
Use the two calls it is made of when you have both:
bwa_scene_set_ism_room(e, w, h, d, faces); // the shoebox; leaves the static mesh alone
float bv[24]; int bt[36]; bwa_material bm[12];
bwa_box_mesh(w, h, d, faces, bv, bt, bm); // its 12 inward-facing triangles
// concatenate bv/bt/bm with your own geometry, then commit ONE mesh:
bwa_scene_set_mesh_mat(e, all_verts, nv, all_tris, nt, all_mats);Neither of those is order-dependent, and bwa_box_mesh is pure (no engine handle, no allocation), so
composing never means re-deriving the box's winding and inward-facing normals yourself. Check its
return value: it refuses the same dimensions bwa_scene_set_ism_room refuses, and there is no
engine to carry a reason, so false is the whole report and it writes nothing.
Clearing geometry does not need a decoy. bwa_scene_set_mesh_mat(e, NULL, 0, NULL, 0, NULL)
removes the static mesh. A malformed partial mesh (vertices but no triangles, counts disagreeing with
pointers) is still dropped intact and leaves the previous geometry in place, so the engine
recognizes the clear only when both pointers are NULL and both counts are non-positive.
void bwa_source_set_occlusion (bwa_engine* e, bwa_source s, bool on);
float bwa_source_get_occlusion (bwa_engine* e, bwa_source s); // 1 = clear .. 0 = blocked (HUD)
void bwa_source_set_orientation(bwa_engine* e, bwa_source s, float qx, float qy, float qz, float qw);
void bwa_source_set_directivity(bwa_engine* e, bwa_source s, float weight, float power); // 0=omni/.5=card/1=fig8
typedef enum { BWA_DIR_OMNI = 0, BWA_DIR_CARDIOID = 1, BWA_DIR_FIGURE8 = 2 } bwa_directivity;
void bwa_source_set_directivity_preset(bwa_engine* e, bwa_source s, bwa_directivity pattern); // OMNI disables it
float bwa_source_get_directivity(bwa_engine* e, bwa_source s); // 1 = on-axis/omni .. 0 = null (HUD)The setters are non-blocking, enqueue-only (safe in the hot loop). The off-thread sim ray-traces
at a low rate and publishes a per-source scalar (+ a 3-band transmission tilt for occlusion) that the
audio thread ramps: never a jump. Occlusion and directivity are independent (a source can be
directional without being occluded). The _get_ reads return the latest published scalar for
HUD/diagnostics and are safe to poll. Occlusion is a no-op without the Steam Audio build (use the
manual path below).
Both _get_ reads gate on the handle, not on the publish slot alone. A stale, destroyed, or
recycled handle reads the neutral value (1: clear, on-axis) the same instant
bwa_source_is_playing goes false, which is the same rule bwa_source_get_playhead_frames
follows. Without that gate the answer would be worse than one block stale: nothing rewrites the
publish slot on a destroy, so a destroyed source would report its last occlusion forever. Every
per-handle readback answers for the handle you hold, never for the slot's history.
Directivity works in every build. Same weighted-dipole model, |(1-w) + w·cos θ|^p, two
renderers: with a Steam scene the occlusion sim evaluates it (~10-30 Hz, published + ramped);
without one (no SDK, or SDK with no scene) the audio thread evaluates it per block from the
source's forward axis and the active listener. Both are walk-correct; the readback reports
whichever path is live. Don't expect bit-identical gains between the two (update rates differ),
just the same pattern.
void bwa_source_set_occlusion_manual(bwa_engine* e, bwa_source s, float level, const float bands[3]);Drives the same handle-gated, audio-thread-ramped publish path the ray-tracing sim uses, from
your own game logic. level is broadband transmittance (1 = clear .. 0 = blocked); bands
(optional; NULL = broadband only) is a low/mid/high tilt in [0,1] rendered as the same 3-biquad
transmission EQ, so a wall muffles rather than only attenuating. The two multiply: level
carries the broadband loss and bands is only the tilt on top of it, so pin the tilt at 1 in its
loudest band and let the other two say how much more they lose. A triple that repeats the
broadband figure charges it twice and the source falls out of hearing. That is the convention the
sim publishes on: it sets level to the loudest band and divides the tilt through by it, so a
normalized triple is what the audio thread expects from either producer.
This is how a no-SDK build gets gameplay-driven occlusion ("behind a door the game knows about",
underwater, muffled-behind-a-menu) with identical click-free rendering. Don't drive one source
from both this and the sim (bwa_source_set_occlusion): the sim republishes every tick and wins.
void bwa_source_set_doppler (bwa_engine* e, bwa_source s, bool on);
void bwa_source_set_air_absorption (bwa_engine* e, bwa_source s, bool on);
void bwa_source_set_loudness_comp (bwa_engine* e, bwa_source s, bool on);
void bwa_source_set_proximity (bwa_engine* e, bwa_source s, bool on);
void bwa_set_speed_of_sound (bwa_engine* e, float meters_per_s); // engine-wide; liveOpt-in, per source, default off, and (unlike occlusion/directivity) pure per-voice DSP that needs no Steam Audio build. All derive from the live source↔listener distance, recomputed each block from the committed positions.
- Doppler renders the source through its acoustic propagation delay (
distance / c). A per-voice fractional delay line glides toward that delay each block; the glide rate is the pitch shift (approaching → up, receding → down), so no velocity input is needed: it falls out of motion. The delay (hence the effect) saturates past ~8 m, which bounds the ring; enabling adds the real propagation latency. Best for fast movers; subtle for slow ones in a small room. - Air absorption is a distance-driven one-pole high-frequency low-pass (far sources sound duller): cutoff falls ~650 Hz/m from 18 kHz near, down to a ~1.2 kHz floor. Subtle at a few meters, pronounced for sources placed at large virtual distances.
- Loudness compensation is the perceptual counterpart: distance attenuation takes level, and at lower levels the ear also loses LF sensitivity (ISO 226), so an attenuated source reads thin as well as far. This restores part of the body with a one-pole LF shelf (~250 Hz) whose boost tracks the attenuation the panner applied: +0.4 dB per dB taken, capped +8 dB. It's a stylization ("far, not tinny"), not physics; leave it off for strict realism.
- Proximity is loudness compensation's near mirror: an LF shelf (~300 Hz) that rises as the
source closes inside ~1 m (0 dB at the radius, up to +6 dB at the head): the spherical-wavefront
proximity effect. In a walkable volume this is the missing half of distance: at arm's length a
source should read as bass, not just level. Pair with
bwa_set_near_spread(the geometric half: a close source also widens). - Speed of sound (engine-wide, not per source): everything that renders a propagation delay
derives from
c: Doppler (delay and pitch-shift magnitude) and the image-source reflection delays. Default 343 (air); 1480 is underwater; small values exaggerate Doppler for slow-motion effects. Live: a change glides every delay to its new target (bends, never steps). Delays saturate against their ring capacity (~40 ms at 48 kHz), so extreme slow motion caps. Clamped to [30, 20000]; the Steam sim's ray clock is phonon's own and does not follow it.
All are non-blocking, enqueue-only, ramp on the audio thread (coefficients and gains glide across the block; no zipper), and are independent of each other and of the panner / profile. They apply to the direct path only: the reflection wet send is tapped before them, so reflections keep their own propagation. They do not affect ambisonic beds (world-locked, no position).
void bwa_source_set_attenuation_override(bwa_engine* e, bwa_source s,
float ref_dist, float rolloff, float min_gain);Override the layout's distance-attenuation curve for one source, using the same formula with
this source's own parameters: atten = clamp((ref/max(d,ref))^rolloff, min_gain, 1), d the
source→primary-listener distance. The two useful shapes: rolloff 0 = constant level at any
distance (a direction-only cue such as narration, UI, or a beacon, which the layout-global curve
can't express), and a steeper/shallower curve than the room default for content that should die
faster or carry farther. ref_dist <= 0 clears the override (back to the layout curve). The gain
solve applies it by ratio, so it's panner-agnostic and composes with spread, dual-band,
decorrelation, and loudness compensation (which tracks the override's curve: a constant-level
source gets no LF compensation); the reflection distance→wet send keeps its own mapping. Ramps
like any gain change; per-frame-safe; beds unaffected (no distance).
void bwa_source_set_spread(bwa_engine* e, bwa_source s, float amount); // 0 = point (default) .. 1 = wide
void bwa_source_set_extent(bwa_engine* e, bwa_source s, float width, float height); // anisotropic w/h
void bwa_source_set_size (bwa_engine* e, bwa_source s, float radius_m); // metric alternative: radius in m
typedef enum { BWA_SPREAD_LOBE = 0, BWA_SPREAD_MDAP = 1, BWA_SPREAD_SPECTRAL = 2 } bwa_spread_mode;
void bwa_set_spread_mode(bwa_engine* e, bwa_spread_mode mode); // engine-wide; live A/BAngular width of a source. A waterfall, a crowd, an engine room, or ambience shouldn't
collapse to a single point: raise spread and the source's energy fans out across the speakers
around its direction.
bwa_source_set_extent is the anisotropic form (BS.2127-style width/height): a shoreline is
wide but not tall, rain is tall but not wide. Separate horizontal and vertical extents, each 0..1;
equal values behave exactly as the isotropic spread, and a later bwa_source_set_spread resets to
isotropic (last call wins). The ring modes squash their virtual-source cap per axis (a 1×0 extent
renders as a horizontal arc of panner solves), the lobe stretches its angular falloff on the
extent ellipse. Width/height are room-referenced (anchored to the room's up axis), so straight
overhead the split is inherently ill-defined; BS.2127's polar extent shares that singularity. So
an anisotropic source's width and height axes can flip as a moving source passes near straight
overhead. Isotropic spread avoids that flip by carrying a
transported frame. Rides everything
spread rides: the size/near floors (they floor both axes, the larger
axis drives decorrelation), constant power, per-frame-safe.
Prefer bwa_source_set_size when the content has a physical size: it floors the spread at the
angle the radius subtends from the tracked listener (asin(r/d), fully wide once the listener is
inside the source), so a 2 m waterfall stays 2 m wide as the listener walks; an angular spread
changes physical size with distance. The larger of the two knobs wins, and a sized source subsumes
bwa_set_near_spread (engulfment is the d < r case). All three are per-frame-safe.
It's implemented in the per-block gain solve, not the sample loop, and renormalized to the
panner's own power: widening never changes loudness, and the perceived direction stays put.
It's panner-agnostic (works over DBAP/SPCAP/VBAP), and the change ramps click-free like any
gain change. Three render modes sit behind the knob (bwa_set_spread_mode, an atomic live A/B
like the panner switch; sources with spread 0 are unaffected either way). Full treatment of the
modes: spatialization.md.
- LOBE (default): the panner's point gains blend toward a width-controlled lobe. One solve, smooth and cheap, but the extent reshapes gains solved for a point.
- MDAP (Pulkki): a ring of virtual sources (cone half-angle =
spread·90°) each panned with the selected panner and summed, so the extent inherits the panner's character. ~13× the gain-solve cost, block-rate and dirty-gated. - SPECTRAL (Zotter and Frank's phantom-source widening): 6 bands, each panned to its own direction inside the cone (the LF band stays put). Different frequencies come from different speakers, so there are no coherent copies to collapse or comb-filter as the tracked listener walks: extent without decorrelation noise. ~6 band filters + 6 gain sets per wide voice; point sources pay nothing. With dual-band on, the sub-700 Hz bands take the amplitude norm, so the two A/Bs compose.
LOBE/MDAP + decorrelation and SPECTRAL are two different answers to the same phantom-collapse problem: A/B/X them (the playground's blind harness has rows for both) and keep the winner.
void bwa_set_decorrelation(bwa_engine* e, bool on); // off by default; live A/BLOBE and MDAP both feed every speaker the same signal at different gains: coherent
copies, which collapse to phantom images between speakers and comb-filter as the tracked listener
walks. bwa_set_decorrelation fixes that: a spread source's energy splits into a coherent share on
the normal path and an incoherent share routed through per-speaker sparse velvet-noise filters
(~30 taps over 30 ms, time-domain, no onset latency; Välimäki/Schlecht's velvet-noise
decorrelator). The split follows spread (a point source is untouched; at full spread the wide
part is fully decorrelated), power is conserved (incoherent energy adds), and the toggle ramps:
a click-free live A/B. This is what makes wide sources read as extent rather than several
copies, and it is the same decorrelator bank the parametric bed's diffuse stream uses.
void bwa_set_near_spread(bwa_engine* e, float radius_m); // 0 = off (default); liveNear-listener widening: a point source flying at the head physically subtends a growing solid
angle, but a point panner collapses it into the nearest speaker and snaps it across the head as it
passes. With a radius set, every source's spread is floored at 1 − dist/radius: untouched
beyond the radius, fully wide at the head. radius_m ≈ 1.0 is a good start. The widening rides the
selected spread mode and (when enabled) the decorrelators, and the changed gains ramp like any
solve. Engine-wide policy; it takes effect with each gain re-solve (continuous under tracking).
void bwa_set_hole_spread(bwa_engine* e, float strength); // 0 = off (default); liveHole-aware widening: the other geometric floor. The CAVE array is a barrel with nothing
covering the poles, so a source aimed into a pole is carried by the big hull triangle that closes
the hole: a split image, not a phantom. With this on, a source's spread is floored by how far
its bearing sits from the nearest speaker: 0 until that gap exceeds the array's own mean
speaker spacing, then rising to fully wide at a 90 degree gap, scaled by strength (1.0 is the
honest width, clamped at 2). An array that surrounds the listener derives no floor at any
bearing, though the knob still pays its measurement (one dot product per speaker plus one arc
cosine per voice per solve). It composes with the other spread floors as a max, rides the
selected spread mode and the decorrelators, and re-solves every source on the next block, static
ones included. No effect in BWA_PROFILE_BINAURAL. Design and measured numbers:
spatialization.md → "Array holes".
typedef enum { BWA_SRC_DEFAULT = 0, BWA_SRC_PROP, BWA_SRC_VOICE, BWA_SRC_AMBIENCE, BWA_SRC_UI } bwa_source_kind;
void bwa_source_preset(bwa_source_kind kind, bwa_source_desc* out); // pure, no engine handle
bwa_source bwa_source_create_desc(bwa_engine* e, const bwa_source_desc* d); // create + apply, in one
bool bwa_source_apply(bwa_engine* e, bwa_source s, const bwa_source_desc* d); // every knob, one call
bool bwa_source_get_desc(bwa_engine* e, bwa_source s, bwa_source_desc* out); // what it is set toConfiguring one source fully takes most of the bwa_source_set_* calls. This is
Situation tuning applied to a source instead
of the engine: bwa_source_kind names what the source is, bwa_source_preset fills a complete
configuration for it, and bwa_source_apply pushes the lot.
bwa_source_desc d;
bwa_source_preset(BWA_SRC_PROP, &d); // a physical object in the room
d.doppler = true; // this one moves fast
bwa_source generator = bwa_source_create_desc(e, &d);Fill-then-apply rather than one apply_preset(e, s, PROP), for the same four reasons the engine
tuning gives. You can print what a source is set to: the only per-source getters are
occlusion and directivity, and those report the sim's live value, not your setting. Preset,
override a field, apply, composes where apply-then-re-override is order dependent. The preset is
pure, so a tool can show the table off-hardware. And two sources diff field by field, which is how
you find the one that sounds wrong. It also gives a source a reset: fill a preset, apply, done.
What is in, and what is out. The struct carries configuration: the settings that describe the source and stay put. Three things are deliberately absent. Position and orientation are per-frame (latest-wins, pushed every frame, commit gated), so they belong to the frame loop. Playback state (play, stop, pause, seek, queue, fade) is what the source is doing, not what it is; an apply must never restart a sound. Manual occlusion is a live value your game publishes per frame, the same kind of thing the sim publishes, so the desc carries the on/off and not the level. Push data is a feed, not a setting.
This struct's zero is NOT its default. A zero-filled bwa_source_desc means gain 0 (silence)
and pitch 0 (invalid), so a zero-init mistake has to fail loudly instead of silently
misconfiguring the source. struct_size is what makes it fail: bwa_source_apply refuses a struct
whose size is wrong, which is exactly what a zero-init looks like. Note this is the opposite call
from bwa_desc, whose zero is its default, and it is the same call bwa_tuning makes. Always
start from bwa_source_preset.
Out-of-range finite values clamp to the documented range, exactly as the individual setters
clamp them. Non-finite values refuse the whole apply, because landing half a configuration is
worse than landing none. A stale or destroyed handle is the usual silent no-op and still returns
true: the desc was valid, the source was not. bwa_source_get_desc returns false there instead,
because there is nothing to report.
Cost. bwa_source_apply is per-frame safe and non-blocking, like the setters it wraps, and it
is one command on the ring for the fifteen knobs the audio thread owns. That matters:
bwa_play_oneshot already documents dropping when the ring is momentarily full, so a
fifteen-command spawn is real pressure. Steal priority is control-side state and costs nothing.
Occlusion, pathing registration and directivity go to the sims, not the ring. Directivity is the one
call that can cost a second ring slot, and only when the pattern actually changes, so an ordinary
omni source stays at one command.
Round-trip. bwa_source_get_desc fills struct_size, so you can edit the result and hand it
straight back: feeding a readback into bwa_source_apply is a no-op. It reports what you set;
for what the sim is currently doing to a source, use bwa_source_get_occlusion and
bwa_source_get_directivity, which report live per-block values. A gain fade in flight already
reads its target, because bwa_source_fade_to is a gain setting spread over time.
bwa_source_fade_out is a stop, not a gain setting, so it leaves the configured level alone.
Read this before treating a preset as a recommendation. Nothing here is measured. A kind
differs from BWA_SRC_DEFAULT only where a doc already argues the case, and every other field sits
at the engine default. Where a value had to be picked it is an endpoint the ABI already names
(1 = wide, 255 = protected, rolloff 0 = constant level), never a tuned middle number.
| Field | DEFAULT |
PROP |
VOICE |
AMBIENCE |
UI |
|---|---|---|---|---|---|
gain / pitch |
1 / 1 | 1 / 1 | 1 / 1 | 1 / 1 | 1 / 1 |
priority |
128 | 128 | 255 | 128 | 255 |
group |
0 | 0 | 0 | 0 | 0 |
spread |
0 | 0 | 0 | 1 | 0 |
extent_height |
-1 | -1 | -1 | -1 | -1 |
size_m |
0 | 0 | 0 | 0 | 0 |
occlusion |
off | on | on | off | off |
reverb / reverb_distance |
off | on | on | off | off |
reverb_send |
1 | 1 | 1 | 1 | 1 |
air_absorption |
off | on | off | off | off |
proximity |
off | on | off | off | off |
doppler |
off | off | off | off | off |
loudness_comp |
off | off | off | off | off |
early_reflections |
off | off | off | off | off |
pathing |
off | off | off | off | off |
atten_* |
layout curve | layout curve | layout curve | layout curve | 1 m, rolloff 0, floor 1 |
directivity_* |
omni | omni | omni | omni | omni |
Why each non-default value:
PROPproximity, air absorption: intent, from the header's own arguments. Proximity is the missing half of distance in a volume you can walk up to; air absorption is what distance does to timbre.PROPandVOICEocclusion, reverb, reverb distance: intent. A real object is occluded by real geometry and excites the room, wetter far and drier near.VOICEdrops air absorption and proximity: intent. Both trade intelligibility for realism, and a talker rarely gets a walk-up cue.VOICEandUIpriority 255: the ABI's own "protected" endpoint, not a tuned number. A full pool must drop an effect before it drops a line or a click.AMBIENCEspread 1: the ABI's documented wide endpoint. Dial it down, or usesize_mfor a physical extent that holds as the listener walks. No reverb send, because ambience content usually already carries its space and sending it to the shared bed double-counts. That is the same argument that keeps the CAVE room itself out of the image-source model.UIattenuation override:rolloff0 is the ABI's own "constant level at any distance, a direction-only cue that never fades". 1 m is the canonical reference distance and a floor of 1 keeps the minimum out of the way.- Doppler stays off everywhere, including
PROP. The header calls it best for fast movers and subtle for slow ones, and "prop" says nothing about speed. Turn it on for the ones that move. - Early reflections stay off everywhere. They need a room and cost O(N) in sources, so they are
an explicit per-source opt-in, not something a preset spends. Applying a desc that asks for them
with no room set leaves them off, says so in
bwa_last_error, and applies the rest. - Directivity stays omni everywhere, even for
VOICE, where a mouth is a cardioid. A pattern without an orientation aims at room-ahead, and the orientation is per-frame, so a preset that quietly nulls an un-aimed source is a trap. Set the pattern yourself once you aim the source.
typedef enum { BWA_TEST_OFF = 0, BWA_TEST_SINE = 1, BWA_TEST_NOISE = 2 } bwa_test_kind;
void bwa_set_test_signal(bwa_engine* e, uint32_t channel, bwa_test_kind kind, float gain);
uint32_t bwa_get_bus_levels(bwa_engine* e, float* peaks, uint32_t cap); // last block's per-channel output peak
uint32_t bwa_get_active_voices(bwa_engine* e); // last block's active voice count
bool bwa_get_health(bwa_engine* e, bwa_health* out); // xruns + load; see below
uint64_t bwa_get_xruns(bwa_engine* e); // device dropouts, one linebwa_get_active_voices is the voice-pool gauge next to the meters: the audio thread publishes each
block's active count (playing, sound bound; paused voices count, they haven't ended). Poll it for
HUDs or health monitoring; it reads 0 until audio runs.
bwa_set_test_signal drives a single output channel with a built-in signal (660 Hz sine or white
noise), injected after the per-speaker align stage, so it carries no trim, no delay and no room
EQ. It is deaf to the calibration by design.
channel is in [0, bwa_get_channel_count()); anything else is ignored.
That deafness is what makes it a speaker-check and wiring tool: walk a tone across every channel to
confirm the channel→speaker map and find a dead speaker. It is not a spatial path: it bypasses the
panner, so don't use it to "place" a sound. Per-frame-safe, takes effect next block,
no bwa_commit needed. Any number of channels at once; gain 0 / BWA_TEST_OFF silences one.
Works in every profile (cave/cave_both: a raw tone on that Digiface channel; the headphone
profiles: that bus channel HRTF'd as its virtual speaker; in binaural the tone rides the
diffuse/virtual-speaker path, since only point sources render direct). Needs no SDK.
It is not a level check, and that follows from where it enters. Post-align means the tone
carries no gain_db, no delay_ms and none of the correction FIRs, so it sounds the same
before and after calibration. That deafness is what makes it the right wiring tool, because a
dead channel is dead whatever its trim says. To hear a trim, use the direct channel route
below: it enters at the panner's output, so it takes the whole align stage.
#define BWA_CHANNEL_AUTO (-1)
void bwa_source_set_channel(bwa_engine* e, bwa_source s, int32_t channel);bwa_source_set_channel plays a source out of exactly one output channel with no spatial
processing. The one-hot gain vector goes in at the panner's output, upstream of the whole output
stage, so the routed voice takes every align trim and delay, the room EQ, the master gain and the
limiter. It is subject to the calibration by design, which is the opposite of the test signal above.
channel is in [0, bwa_get_channel_count()); BWA_CHANNEL_AUTO restores normal
panning. Out of range is refused and sets bwa_last_error. Per-frame-safe, no bwa_commit needed.
BWA_CHANNEL_AUTO is the only negative value the call accepts. Any other negative is refused like
a too-large index, not read as AUTO: a bad index that quietly means "restore panning" leaves the
source panned, reports nothing, and reads as a single-speaker reference that never took.
Two jobs. First, the psychophysics ground truth: drive one real speaker with a real stimulus and A/B it against the phantom the panner puts at the same place. Second, a wiring check you can run with content instead of a tone.
This is not bwa_set_test_signal. That one injects a built-in sine or noise onto a raw channel
AFTER the align stage, which makes it a wiring tool and not level-comparable with anything the
engine renders. This one replaces the voice's panner solve with a one-hot gain vector, at the
panner's output. Two consequences, and both are the point:
- Entering upstream is what makes the reference comparable in level and in path with the phantom you are testing against. Both sides take the same output stage.
- The route is just another gain target, so switching it on or off RAMPS across a block. No click.
The route is dry by design. While it is on, distance attenuation, spread/extent/size,
near-widening, hole spread, CAP, dual-band, occlusion, directivity, the reverb and
early-reflection sends, pathing, Doppler and air absorption are all suppressed. What reaches the
speaker is the asset's samples times the source gain times the group and master gains, which is
what "no spatial processing" has to mean if the condition is to be deterministic. Each suppression
drives that stage's own target rather than switching it off, so entering and leaving the route
glides the Doppler line and the shelves out instead of stepping them. Position and every knob those
stages read are remembered and take effect again the moment you go back to BWA_CHANNEL_AUTO.
Playback rate (bwa_source_set_pitch) and the pause gate still apply: they are content controls,
not spatial ones. The route survives a bwa_source_play, so a reference source keeps its channel
across trials.
Mono point sources only. A bed voice (bwa_bed_play, a 4/9/16-channel asset) ignores the call and
keeps decoding to all speakers; route a mono copy instead. The profiles: cave/cave_both put the
content on that Digiface channel and speaker; cave_sim HRTFs that bus channel as its virtual
speaker; binaural renders the voice as a point source AT that speaker's surveyed position, dry,
because point voices never reach the bus there. That is the same thing your ears get in cave_sim.
bwa_get_bus_levels is the matching readback: each output channel's last-block peak |sample|
(linear), measured at the very end of the render, after align, the test signal, and the limiter.
That is exactly what the device channel received. It fills up to cap floats and returns the
count filled (bwa_get_channel_count() when cap allows). Per-frame-safe (relaxed atomic reads, no
locks); levels read 0 until audio is
running. Drive channel meters or a speaker-activity display with it; the playground lights each
speaker gizmo from this.
typedef struct bwa_health {
uint64_t blocks, xruns, dropped_frames, driver_resyncs, late_blocks, stream_starves;
float peak_load; // worst block's render time / block period, last 4-5 s
uint32_t device_lost; // nonzero: the device went away; the engine is host-pacing silence
} bwa_health;
bool bwa_get_health(bwa_engine* e, bwa_health* out); // false = this setup cannot measure
uint64_t bwa_get_xruns(bwa_engine* e); // the one-line formEverything else on this page describes the render. None of it can tell you the device asked for a block and did not get one. That's the failure that makes a clean render sound broken, and the one thing an offline render cannot reproduce by construction. That is what these count.
The fields answer two different questions with two different fixes:
xrunsis the device running on without us. Its sample position jumped past where the last callback said the next one would land, so it clocked out audio nobody rendered. Fix upstream: a bigger buffer, fewer competing loads, a look at the driver.dropped_framesis how much audio those gaps swallowed.late_blocksis us overrunning the block period, which is what eventually produces xruns. Fix downstream: a cheaper scene, fewer voices, and watchpeak_load; it is the worst single block's render time as a fraction of the period, so 1.0 means a block exactly consumed its budget and anything near it is living dangerously. It covers the last 4 to 5 seconds of stream time, not the whole run, so a slow first block or one old stall stops showing once it is 5 s old. On the web AudioWorklet sink the clock under it isDate.now, whole milliseconds, so read it in ms there rather than as a percentage of a 2.7 ms period.stream_starvesis neither. The device kept its deadline and a streamed voice had nothing to give it, because the disk thread didn't refill in time; that block's tail rendered silence. It sounds identical to a clip ending, which is exactly why it is counted separately.device_lostis none of the three. The device itself went away: unplugged, reset by its driver, or its session torn down. The engine keeps rendering, paced from the host clock, so clocks and playheads stay live and every scheduled play still lands - and the audio is silent. Nothing reopens on its own. Callbwa_stopthenbwa_startto pick a device up again, or leave it and show the state. A backend that cannot detect a lost device always reports 0.
Read them against blocks. One xrun in two million blocks is a machine hiccup; one in a thousand is
a configuration to fix.
The return value is the load-bearing part. bwa_get_health returns whether the numbers mean
anything at all. It is false when nothing here can observe a dropout: before bwa_start, on the
manual sink (no clock, no deadline; it cannot miss one), or on an ASIO driver that never flags a valid
sample position, leaving nothing to compare against. In every one of those cases xruns is zero, and
reading that zero as a clean bill of health is precisely how a starved device goes unnoticed.
bwa_health h;
if (!bwa_get_health(e, &h))
log("device health unavailable on this driver - xruns cannot be detected here");
else if (h.xruns)
log("%llu dropouts (%llu frames) over %llu blocks, peak load %.2f",
h.xruns, h.dropped_frames, h.blocks, h.peak_load);Check the boolean once after bwa_start on an unfamiliar driver; poll bwa_get_xruns for a HUD
afterwards. Both are control-thread, per-frame-safe (relaxed atomic reads), and every count is
monotonic since start. peak_load is the one field that is not a count, and it is windowed.
typedef void (*bwa_output_fn)(void* user, const float* planar, uint32_t channels, uint32_t nframes);
void bwa_set_output_capture(bwa_engine* e, bwa_output_fn cb, void* user);bwa_set_output_capture taps the final device-bound output: post-limiter, exactly what reaches
the device. The callback runs on the audio thread, once per block, with planar channel-major
data (planar[c*nframes + i]). channels is the primary device's channel count: the array count
(bwa_get_channel_count) for cave/cave_both, 2 for binaural/cave_sim. Same audio-thread contract as any
callback here: copy out only, no alloc/lock/syscall/file I/O; write to a ring your own thread
drains to a file or comparison buffer. Pass cb = NULL to stop, and keep user alive until after
the NULL set plus one block.
Two uses: recording (grab what you're hearing; the playground's F9 writes the binaural output
to a WAV this way) and offline sanity / golden-audio tests. For recording, any sink works. For
golden tests, use the manual sink below: it's deterministic, where the null sink is only
paced (a real thread, a wall clock, a run-varying block count).
const float* bwa_render_block(bwa_engine* e, uint32_t* channels, uint32_t* nframes);Set bwa_desc.sink = BWA_SINK_MANUAL and the engine creates no device and no audio thread: you pump one block
at a time on your own thread. Each call renders exactly one block (bwa_desc.block_size frames) of the
profile's primary output (binaural/cave_sim: 2 ch; cave/cave_both: bwa_get_channel_count() ch) into
engine-owned memory and returns a pointer (planar, channels * nframes floats, valid until the
next call or bwa_stop). Fills *channels/*nframes (either may be NULL). Returns NULL if the
engine isn't started or the sink isn't MANUAL.
The timestamp is a pure sample counter (no wall clock), so a fixed input + fixed call sequence
renders bit-identically every run; that reproducibility is what makes a committed golden meaningful
(see test/golden_test.c: push a tone at a fixed position → render N blocks → compare per-channel
energy against a reference). Same caveat as capture: only the synchronous DSP is reproducible. The
async sim layers (Steam occlusion, reflection, pathing) run on their own wall-clock-timed threads
(30 / 12 / 10 Hz), so keep golden renders off them, or drive the deterministic entry points
(bwa_source_set_occlusion_manual instead of the ray-traced sim). You can still register a capture
callback on a manual sink, but reading bwa_render_block's return value directly is simpler.
typedef enum { BWA_PAN_DBAP = 0, BWA_PAN_SPCAP = 1, BWA_PAN_VBAP = 2 } bwa_panner;
typedef enum { BWA_DECODE_DEFAULT = 0, // reserved for default-init; NOT a named algorithm
BWA_DECODE_ALLRAD = 1,
BWA_DECODE_EPAD = 2,
BWA_DECODE_LAYOUT = 3 } bwa_bed_decoder; // bwa_desc.bed_decoder; 3 = the layout's bed_decode
void bwa_set_panner(bwa_engine* e, bwa_panner panner); // load-time or live (atomic switch)
void bwa_set_dual_band(bwa_engine* e, bool on); // live A/B; wraps the selected panner
void bwa_set_dual_band_cap(bwa_engine* e, bool on); // live A/B; ITD-corrects dual_band's low band
void bwa_set_spcap_focus(bwa_engine* e, float focus, float density); // SPCAP tuning; <= 0 = default
float bwa_spcap_focus_default(const float* positions, uint32_t n); // pure: what the geometry implies
uint32_t bwa_get_speakers(bwa_engine* e, float* xyz, uint32_t cap); // read back the layout; NULL xyz = count onlybwa_set_panner chooses the per-source panner behind the master bus:
- DBAP (default) is listener-relative and recomputed per block from the tracked pose. This is the panner for a moving observer roaming the array.
- SPCAP is a smooth, all-speaker, placement-correcting sweet-spot panner for a fixed observer: don't track, set the sweet spot once. It conserves loudspeaker power across an uneven array.
- VBAP is the sharpest: the 2-3 speakers of the containing triangle carry a source. Also fixed-observer, best on a cleanly-triangulable array; it falls back to DBAP otherwise.
The switch is atomic, so flipping it live is safe; the layout tool's B key A/Bs panners
exactly this way.
bwa_set_spcap_focus is SPCAP's own tuning, and it does nothing under the other two panners.
focus is the lobe sharpness in ((1+cos)/2)^focus: raise it to concentrate a source on fewer
speakers (tighter image, harder edges), lower it to spread the source out (smoother, blurrier).
density is the exponent of the placement-correction kernel that de-biases a clustered array,
and 2.0 is right almost always. Pass 0 or less for either argument to revert that one to its
default.
The focus default is derived from your array, not a constant: the geometry-derived lobe from
spatialization.md (the 26-speaker cube grid lands near 12.7).
bwa_spcap_focus_default computes the same number for an arbitrary set of speaker positions
(3 floats each, the bwa_panner_gains_batch convention) without an engine, so a tool can show
what a layout implies before you override it. It allocates scratch, so keep it off the audio
thread, and it returns 0 on bad arguments or a failed allocation. bwa_panner_gains_batch takes the same two knobs
with the same sentinel, so you can score a layout at the tuning you will ship ("Offline panner
and bed evaluation" below).
Both knobs are live and per-frame-safe, and the change reaches every source on the next
block, including sources that never move. Neither value lives in the layout file: like the rest
of the live A/B surface, persisting a dialed value is your application's business. Dial by ear
with the playground or the layout tool's preview, then set it at startup. You do not have to
settle it by ear alone: bwa_validate --focus 4,32 sweeps the knob in one measurement session
and reports the comb depth each setting costs, against one speaker driven alone as the floor
(docs/validation.md, including where that sweep has power and where it does not).
bwa_set_dual_band (off by default, live-toggleable) wraps the selected panner. It splits
each source at ~700 Hz, then pans the low band with amplitude (pressure / velocity-vector)
normalization and the high band with the panner's usual power (energy-vector) normalization
(see Dual-band panning and rV in the
glossary). You get sharper low-frequency localization for a near-centered listener.
The panning direction is unchanged; only the low band's level/coherence differs. It is
sweet-spot dependent like VBAP, so for a roaming listener it's a by-ear / measurement call.
bwa_set_dual_band_cap (off by default, live-toggleable) corrects that low band's interaural
time difference, and requires bwa_set_dual_band since the low band is the only thing it
touches. CAP constrains the interaural component of the summed field, so the rendered ITD comes
out exact and stays exact as the head turns, which plain dual-band does not do. It is a
projection on top of the selected panner, not a fourth panner: facing the source it reduces to
the seed, and it fades out with bwa_source_set_spread. This is the only feature that reads
head orientation into the speaker path, so it wants a real tracked pose. Two known
exclusions: BWA_SPREAD_SPECTRAL bypasses it entirely, and the offline
bwa_panner_gains_batch never solves a low band, so it cannot be swept there. Theory, limits,
and the open crossover question: spatialization.md.
bwa_desc.bed_decoder reserves value 0 for default-init, the sokol convention. A zero-filled
bwa_desc therefore asks for whatever the engine currently defaults to rather than naming an
algorithm, so that default can move later without an ABI break and without silently re-pointing a
caller who did name one. engine.c's resolve_bed_decoder is the single line that says what the
default is today (AllRAD). Every public enum also carries a *_FORCE_U32 = 0x7FFFFFFF member that
pins its width to 32 bits. C leaves an enum's underlying type implementation-defined, and this DLL is
consumed by C# P/Invoke and a GDExtension, both of which marshal enums as 4-byte ints. Never pass it.
bwa_desc.bed_decoder chooses the diffuse-bed SH→speaker decoder. It affects the ambisonic
and reflection beds only, never the point-source panner:
- AllRAD (
BWA_DECODE_ALLRAD, the default): decode to a uniform virtual layout, then VBAP onto the real array. Robust on an irregular array, localizes a touch sharper. A pole with no real speaker within ~60° (a floor-less array's nadir) gets an imaginary speaker whose decode share is discarded: diffuse energy aimed into the hole is dropped rather than smeared onto the nearest ring. - EPAD (
BWA_DECODE_EPAD): energy-preserving decode (Zotter/Pomberger/Noisternig 2012); a panned plane wave's decoded energy is constant over direction by construction, the flattest loudness-versus-direction of the two. AllRAD versus EPAD on the real array is a by-ear call. The FDN's line render follows EPAD too (with AllRAD selected the FDN keeps its house AllRAD render). - LAYOUT (
BWA_DECODE_LAYOUT): a decoder designed elsewhere, carried in the layout file'sbed_decodeblock. The Ambisonic Decoder Toolbox designs it (two-band rV/rE-matched decoders, mixed-order decoders for sparse vertical coverage), writes an AmbDec preset, andtools/decoder/ambdec_to_json.pyconverts that into the layout. The engine applies the matrix as it is: no max-rE taper, AmbDec's phase-matched crossover when it is two-band, and the decoder for the bed's own set and order when the block has one (a full decoder per order,full1tofull3; with no exact match, the nearest full order, higher first, and a one-time note inbwa_last_error). The FDN renders through the HF matrix of the full decoder a 3rd-order bed gets. With a layout that carries no block,bwa_createstill succeeds (reason inbwa_last_error) andbwa_startrefuses withBWA_ERR_LAYOUT. The block is layout-schema.md; the rendering is spatialization.md.
The plain sampling (projection) decode is not selectable: on an irregular array it over-energizes dense speaker regions (dominated by both options above), so it survives only as the engine's automatic fallback when a degenerate layout defeats the chosen build (a failed fallback is silent by design; fix a degenerate survey rather than decoding around it).
The decoder is create-time configuration (the decode matrix is built at bwa_create); see
spatialization.md for the theory.
bwa_get_speakers returns the effective layout (the default grid, or the layout_path file) as
cap*3 floats in channel order, and returns the count filled: min(cap, count), the same
convention as bwa_get_bus_levels. Pass xyz = NULL to query the total count (the same count
bwa_get_channel_count() reports). Use it to visualize or audition the geometry the engine is
actually panning with.
#define BWA_MAX_CHANNELS 64 // capacity: the most speaker channels one engine can drive
#define BWA_DEFAULT_GRID 26 // the built-in grid's speaker count (no layout_path)
uint32_t bwa_get_channel_count(bwa_engine* e); // the ACTIVE channel count (4..BWA_MAX_CHANNELS), fixed at createThe engine's channel count is the layout's speaker count: a layout_path file with
4..BWA_MAX_CHANNELS speakers, or BWA_DEFAULT_GRID (the built-in 3x3x3 grid minus the
center) with no path.
BWA_MAX_CHANNELS (64) is only the capacity. It is a transport bound, not a rig detail: an
ASIO, MADI or Dante endpoint carries 64 channels. A collaborator's 24-speaker array loads a
24-entry layout into the same binary, the device opens 24 channels, and every consumer (panners,
beds, reverb, monitor, calibration) follows. The CAVE starts with 24 speakers and has room to
grow. A 36-speaker CAVE is a new layout file, not a recompile.
The three playgrounds (bwa_playground, the web page, the Godot addon's scene) do not run the
default grid. They load examples/dome_24.json, a generated 24-speaker dome (2 m around a
listening point 1.5 m up, spread evenly over the sphere above the floor), unless you give them a
surveyed file. See the README's bwa_playground section for the lookup order.
A layout can declare its listening point with listening_point_m; without it the engine listens
from the array centroid (see layout-schema.md).
BWA_DEFAULT_GRID (26) is the default grid's count, not the capacity. Do not use one where you
mean the other.
Size meter/speaker arrays from this getter. Use BWA_MAX_CHANNELS only for a static buffer that
must fit any layout.
A failed explicit layout load leaves the engine on the default grid (reason in
bwa_last_error, readable after bwa_create), and bwa_start refuses it with
BWA_ERR_LAYOUT, so a 24-speaker deployment cannot silently render the default grid's 26
channels. Only layout_path = NULL runs the default grid.
uint32_t bwa_panner_gains_batch(bwa_panner panner, const float* positions, uint32_t n,
const float lis[3], const float* srcs, uint32_t nsrc,
float focus, float density, float* out);
uint32_t bwa_bed_gains_batch(bwa_bed_decoder decoder, bool max_re,
const float* positions, uint32_t n,
const float* dirs, uint32_t ndir, float* out);
uint32_t bwa_bed_gains_layout(const char* layout_path, bwa_bed_decoder decoder,
const char* channels, bool max_re, uint32_t band,
const float* dirs, uint32_t ndir,
float* out, uint32_t out_cap, char* err, uint32_t err_cap);
uint32_t bwa_bed_decode_matrix(const char* layout_path, bwa_bed_decoder decoder,
const char* channels, bool max_re, uint32_t band,
float* out, uint32_t out_cap, char* err, uint32_t err_cap);The first two take no engine handle. They run the engine's own solves over a candidate layout you pass
in, so a layout tool scores what will ship instead of a re-implementation. Both are pure and
reentrant: the per-listener cache is per-call state with no globals, so any thread can call them,
including while an engine renders. Both allocate one scratch layout per call and free it
before returning, so never call them from an audio callback. Each returns 0 on a NULL argument,
a zero count, more than BWA_MAX_CHANNELS speakers, or a failed allocation.
bwa_panner_gains_batch writes out[i*n + s], nsrc*n floats, and returns nsrc. It uses the
default DBAP and distance tuning, and shares the SPCAP/VBAP per-listener cache across the batch,
which is what makes a grid sweep cheap.
focus and density are SPCAP's two knobs, the same pair bwa_set_spcap_focus sets live and
under the same rule: pass 0 or less for either one to get the default for this array, meaning
the geometry-derived focus (what bwa_spcap_focus_default returns for positions) and density
2.0. Both are inert under BWA_PAN_DBAP and BWA_PAN_VBAP, which have no lobe to sharpen, so any
value scores the same there. Score at the tuning you will ship: a layout graded at the derived
focus tells you nothing about how it behaves once you dial the knob.
bwa_bed_gains_batch is the diffuse-bed counterpart. It takes plane-wave directions rather
than positions, because a bed is content at infinity. It builds the same AllRAD or EPAD decode the
engine builds for this layout, applies max-rE weighting when you ask for it, and writes ndir*n
gains. Those gains can be negative (SH sidelobes). A layout that scores well here is a good
quadrature for the sphere, which is what ambisonic content wants from an array and what the
point-source panners cannot see. It returns 0 for BWA_DECODE_LAYOUT: a list of positions
carries no imported matrix.
bwa_bed_gains_layout grades a layout file instead, so it sees what the positions batch
cannot: the file's imported bed_decode, its listening point, and its speaker count. It loads the
file the way bwa_create does and builds the decode bwa_start would install, then encodes each
direction and multiplies through, like the batch.
channelsnames the bed: NULL for a 3rd-order full bed,"full1","full2"or"full3"for a full bed of that order, or a mixed-order set name ("1H","2H","2H1V","3H","3H1V","3H2V"). The encode zeroes the channels outside the bed, the way the loader stores such a bed, and the decode is the one that bed would get, by the engine's own pick.max_reapplies the taper by the bed's highest order. It has no effect on an imported matrix, as in the engine.band0 reads the HF matrix (the only one of a single-band decoder), 1 reads the LF matrix of a two-band imported decoder. Built-in decoders read the same matrix for both.out = NULLis a size query: it returns the file's speaker count N and writes nothing. It makes every check the full call makes except the three aboutdirs,ndirandout_cap, so it returns 0, with the same reason, for a file the full call would refuse (for exampleBWA_DECODE_LAYOUTon a file with nobed_decode). Otherwiseoutneedsndir*Nfloats andout_capsays how many it holds.
It returns N on success and 0 on any failure. There is no engine to carry the reason, so it goes
to the optional err buffer (always NUL-terminated, truncated to err_cap - 1). It reads the file
and allocates: pure and reentrant, but never on an audio callback.
bwa_bed_decode_matrix reads back the matrix that bwa_bed_gains_layout multiplies through.
Use it to compare the engine's decode with one designed in another tool, such as the Ambisonic
Decoder Toolbox. It takes the same arguments, minus the directions, and refuses the same calls.
out[s*16 + k]is the gain from ACN channelkto speaker indexs, with SN3D input scaling. The rows are speakers and the columns are channels, the same shape ADT writes. ADT often writes N3D: multiply each order-l column of an N3D matrix by sqrt(2l+1) to get SN3D before you compare.- The columns are in the bed's AmbiX frame: x is room ahead (+z), y is room left (+x), z is room up (+y). Rotate your directions into that frame before you compare.
- The columns outside
channelsare 0, and the max-rE taper is folded in, soouttimes the SN3D encode of a direction is exactly whatbwa_bed_gains_layoutreturns for it. out = NULLis the same size query. Otherwiseout_capmust be at leastN*16.
Expect a broad match, not a bit-exact one. The built-in AllRAD and EPAD differ from ADT's in their
virtual t-design, imaginary-speaker handling and normalization, so compare decoders on rE, rV and
energy over the sphere, not coefficient by coefficient. An imported ADT matrix is the exception:
read it back with BWA_DECODE_LAYOUT and it matches the preset, after the conversion
ambdec_to_json.py applies.
bwa_layout_tool drives the first three: the Score board, the rE coverage overlay, the badness map, and the
optimizer cost all go through them. See layout-authoring.md.
void bwa_set_tracked_room_eq(bwa_engine* e, bool on); // default ON when the layout carries a gridLayouts carrying a room_eq_grid (written by bwa_calibrate --room-eq-grid, one mic placement
per row of a positions file) get listener-tracked LF room correction: each block the engine re-interpolates the
grid's per-speaker modal-cut depths at the live listener position (inverse-distance weights over
the measurement points). The align-stage biquads glide toward them at 24 dB/s, click-free by
construction, fast enough to track a walking listener.
This is the moving-listener answer to the static room_eq, which bwa_start rejects for moving
sessions. It works because the room's mode frequencies don't move with the listener, only how
strongly each mode reads at a position, so one per-speaker fc/Q ladder plus per-position depths
interpolate safely. Mid/HF room correction stays out of the tracked path: it is position-sensitive
at the centimeter scale (calibration.md).
The switch is the live kill switch (off glides every cut to flat, a clean A/B). It's a no-op for
layouts without a grid, and under BWA_PROFILE_BINAURAL, which skips the whole align stage.
void bwa_set_tracked_directivity(bwa_engine* e, bool on); // default ON when the layout carries a modelLayouts carrying a directivity model (the speaker vendor's CLF balloon, converted by
tools/directivity/clf_to_json.py; see layout-schema.md) plus each
speaker's aim get listener-tracked directivity compensation: each block the engine
takes each speaker's angle off its axis toward the live listener against its angle toward
the listening point, looks both up in the model, and hands the align stage the difference
as a broadband gain plus a high shelf at the model's split frequency, glided at 24 dB/s.
Identity at the listening point, so a calibrated layout is unchanged there; a boost with
extra treble as the listener walks off a speaker's axis, a cut as they walk onto one.
Clamped at +/-6 dB broadband and +/-10 dB on the shelf, because past a speaker's beam a
boost raises the room more than the listener.
It is pure gain (no delay moves), which is why it can default on where tracked alignment
cannot. This is the live kill switch (off glides to identity, a clean A/B). A no-op for
layouts without a model, and under BWA_PROFILE_BINAURAL, which has no physical speakers:
that profile skips the whole align stage, and the call still stores its value.
The headphone audition of the array (BWA_PROFILE_CAVE_SIM, and the BWA_PROFILE_CAVE_BOTH
monitor) applies the model too, at its full resolution, as each virtual speaker's physical
loss toward the listener before the decode, so the audition hears the comp's two-band residual
against the loss it corrects. That part has no switch: it is the simulated room, not a
correction. The array output never sees it. What it can and cannot do, and why two bands:
spatialization.md.
Untested on hardware.
void bwa_set_tracked_align(bwa_engine* e, bool on);
void bwa_set_tracked_align_guards(bwa_engine* e, float dead_zone_m, float slew_frames_per_s);The layout's per-speaker trims align the array's arrival times at one point, the array
centroid. Turn this on and the output stage re-references those trims onto the tracked
listener, so time coherence follows the head: per speaker, the extra propagation delay and 1/r
level for |speaker - listener| against |speaker - centroid|.
It is off by default because a moving delay line is a resampling event: a walking listener glides
every speaker's delay at once, a Doppler shift on everything the array plays. Two guards make it
usable; pass 0 or less for either to take its default. The enable and the guards are separate
calls (the same split as bwa_set_limiter / bwa_set_limiter_ceiling): the enable is the
thing you A/B, and toggling it must not quietly reset a guard you dialed. Order does not matter,
and the guards are live (re-read every block).
| Argument | Default | What it does |
|---|---|---|
dead_zone_m |
0.05 | How far the head must move before the engine recomputes anything. Tracker output is jittery; without a dead zone the array glides permanently. 5 cm of slack is 0.15 ms of residual arrival error. |
slew_frames_per_s |
about 63 at 48 kHz | Ceiling on how fast a speaker's delay may change. That rate over the sample rate is the resampling ratio, so it bounds the pitch shift. The default follows a 0.45 m/s closing speed, 0.13% of shift (2.3 cents). |
A listener who outruns the rate limit gets a lagging alignment rather than a pitch-shifted
array, the trade the default takes on purpose. Off is exact, not approximate: while nothing is
displaced the align stage runs its original integer delay tap, bit-identical to a build without
the feature; toggling either way glides. It reads the same active listener position everything
else does (committed pose or internal tracker), touches no gain solve, and re-solves no sources.
No effect in BWA_PROFILE_BINAURAL, which skips the whole align stage (no physical speakers to
align); the call still stores its value. Saturation limits, the level-clamp caveat, the unreported added latency, and why the defaults are
what they are: spatialization.md.
Untested on hardware.
typedef enum { BWA_SETUP_DEFAULT = 0, BWA_SETUP_SEATED = 1, BWA_SETUP_ROAMING = 2 } bwa_setup;
void bwa_tuning_preset(bwa_setup setup, bwa_tuning* out); // pure, no engine handle
bool bwa_apply_tuning(bwa_engine* e, const bwa_tuning* t); // one call, every live knob
bool bwa_get_tuning(bwa_engine* e, bwa_tuning* out); // what the engine is rendering atbwa_get_tuning is the readback half. It fills struct_size, so you can edit the result and hand it
straight back to bwa_apply_tuning. It reports what the engine will render rather than what
you passed: rt clamps several of these (the align guards, near and hole spread, SPCAP focus and
density, and an out-of-range panner or spread mode), and a readback whose job is to keep a UI honest
must not itself report an unsanitized value. This is not per-knob getters, which the live A/B surface
still deliberately lacks; it is one struct the ABI already owns.
That many rendering knobs is a lot to get right. bwa_setup names how the listener uses the
install, bwa_tuning_preset fills a complete tuning for it, and bwa_apply_tuning pushes the lot.
Fill-then-apply rather than one apply_preset(e, SEATED), deliberately. You can print what the preset
chose, which matters because most of these values are contested. Preset, override a field, apply,
composes where apply-then-re-override is order dependent. The preset is pure, so a tool can show the
table off-hardware, the same contract as bwa_spcap_focus_default. And two setups diff field by
field, which is the natural rig-day question.
bwa_setup is orthogonal to bwa_profile. Profile is what you render to; setup is how the
listener uses it. Seated-CAVE, roaming-CAVE and seated-binaural are all real. Create-time config is
not part of a preset: bwa_desc.bed_decoder already defaults correctly, and the profile is yours.
This struct's zero is NOT its default. A zero-filled bwa_tuning would force max-rE off, and
the engine default is on. struct_size exists to make that fail loudly: bwa_apply_tuning
refuses a struct whose size is wrong, which is what a zero-init mistake looks like. Always start from
bwa_tuning_preset.
Read this before treating a preset as a recommendation. Where there is no evidence, the field stays at the engine default rather than a guess.
| Field | Seated | Roaming | Evidence |
|---|---|---|---|
panner |
SPCAP | DBAP | Intent. SPCAP is the documented fixed-observer default; DBAP re-solves per block for a moving one |
dual_band |
on | off | Intent. Sweet-spot dependent, which a seat satisfies and roaming does not |
dual_band_cap |
on | off | Intent, unmeasured on hardware. Its stated target is the seated case; the ITD claim needs the rotating two-mic rig |
max_re |
on | on | Measured. Won every axis of the offline bed sweep, under both decoders and both observer models |
spread_mode |
LOBE | LOBE | Measured. MDAP 19.1 deg against LOBE's 11.9 |
decorrelation |
off | off | Measured. Worse on both axes at the sweet spot when swept properly |
hole_spread |
0 | 0 | Measured. Costs direction, and its benefit is invisible to these estimators |
near_spread |
0 | 0 | Measured mixed, and the useful radius is install specific |
tracked_align |
off | off | Measured, and the largest effect there is (comb 8.54 to 0.80 dB off-center). It needs a calibrated layout and measures worse without one, and a preset cannot know whether you surveyed. Turn it on after bwa_calibrate |
max_re_split |
off | off | No evidence either way |
bed_renderer |
MATRIX | MATRIX | Parametric's claim needs a walking listener, which nothing has measured |
tracked_room_eq |
on | on | Engine default, and a no-op without a room_eq_grid |
tracked_directivity |
on | on | Engine default, and a no-op without a directivity model. Pure gain, identity at the listening point; unmeasured on hardware |
spcap_focus / _density |
0 | 0 | 0 means the array-derived default |
align_* guards |
0 | 0 | 0 means the built-in guards |
Seated and roaming differ in exactly three fields today. That is not an oversight, it is what the evidence supports. The gap should widen after the rig day.
void bwa_set_limiter(bwa_engine* e, bool on); // live
void bwa_set_limiter_ceiling(bwa_engine* e, float linear); // linear peak ceiling in (0..1]; default 0.891251 (-1 dBFS)The final stage on the speaker output. Everything (voices, beds, the reflection/pathing taps, the per-speaker align stage, the test signal) passes through it before the device.
It is linked across channels: one gain, derived from the cross-channel peak, so engaging never
shifts the spatial image. ~1 ms attack / ~120 ms release one-poles, then a hard clamp at the
ceiling. The attack is not lookahead, so the first millisecond of a hot transient clips instead of
overshooting. The ceiling is a linear peak amplitude in (0..1] (like every other gain in the
ABI), so 0.891251 is −1 dBFS; a value above 1 clamps to 1 and a non-positive value is ignored.
This is driver/speaker protection against digital overs and pathological content, not a
mastering limiter. If it engages in normal use, turn the content down. In cave_sim/cave_both
the same limited bus feeds the monitor, so headphones inherit the ceiling too. In binaural only
the diffuse layer passes through it: the direct field bypasses the speaker bus, so the engine
hard-clamps the final stereo at ±1 instead (no ramped limiting on the direct path; content that
clips there is too hot).
bwa_result bwa_load_headphone_eq(bwa_engine* e, const char* path); // load-class: file I/O; NULL/"" clears
void bwa_set_headphone_eq (bwa_engine* e, bool on); // the ramped live A/B; default onThe headphone-side align stage. The HRTF decode assumes acoustically transparent headphones;
real ones color the signal, so a serious binaural chain EQs the headphone flat first. This
corrects the transducer, not the render: the cascade runs on the final device-bound stereo
of every headphone profile (binaural, cave_sim, cave_both's monitor tap) after the HRTF
decode and before the output clamp, so A/B-ing the two renders stays fair, and the array
render never sees it (speakers get the per-speaker align stage instead). In cave it is inert.
The format is AutoEq's ParametricEQ.txt
(github.com/jaakkopasanen/AutoEq: measured,
target-compensated corrections for thousands of headphone models):
Preamp: -6.4 dB
Filter 1: ON PK Fc 105 Hz Gain -4.6 dB Q 0.70
PK/LSC/HSC map onto the engine's RBJ biquads; LS/HS are accepted, a missing Q
defaults to 0.707, OFF filters and unknown lines are skipped. The Preamp line is honored:
corrections boost dips, and the preamp is the headroom that keeps them out of the clamp.
A malformed Filter line, an unreadable file, or a file with no filters fails with
BWA_ERR_CONFIG (bwa_last_error has the reason) and keeps the previous EQ.
Loading is click-free: a running correction ramps out, the new one ramps in. The toggle
crossfades the same way: flip it mid-listen to hear what the correction does. State does not
survive bwa_destroy: reload after rebuilding an engine (the playground's EQ field does this
for you across its render/driver rebuilds). Personalized HRTFs are the other half: SOFA via
bwa_desc.hrtf_path corrects your ears, this corrects your headphones; they compose.
typedef struct { float ir_seconds; uint32_t order, num_rays, num_bounces; int enabled, bake; uint32_t reserved[3]; } bwa_reflections_desc;
void bwa_reflections_config (bwa_engine* e, const bwa_reflections_desc* cfg); // LOAD-TIME (before bwa_start)
void bwa_set_reverb_gain (bwa_engine* e, float linear); // the wet level: live, default 1
void bwa_source_set_reverb(bwa_engine* e, bwa_source s, bool on); // per-frame; gates the wet send
void bwa_source_set_reverb_send(bwa_engine* e, bwa_source s, float gain); // per-source wet-send level (default 1)
void bwa_source_set_reverb_distance(bwa_engine* e, bwa_source s, bool on); // far = wetterA single shared listener-centric reverb bed, decoded straight to the speaker channels and
summed onto the bus. It runs Steam Audio's HYBRID reverb: directional early-reflection
convolution (full ambisonic, order = order) plus a parametric (FDN) tail, decoded across the
array.
(This needs the vendored phonon's alignment patch; see materials.md.) No-op
without the Steam Audio build.
Which reverb should you use? Three reflection/reverb implementations exist and they are complementary, not rival; the recommended configuration (Steam scene + ISM + FDN) never creates this bed at all. See materials.md → "Choosing an acoustics path". Do not run this bed and the ISM together: the bed already contains early reflections, so you would hear them twice (the engine warns once through
bwa_last_error).
Configuration is load-time; the sends are live:
bwa_reflections_configmust land beforebwa_start: the IR length and ambisonic order are baked there. Zero fields take defaults (ir_seconds1.0,order1,num_rays4096,num_bounces16).enabled = 0creates no bed; the engine behaves exactly as without one.bakenon-zero precomputes the reverb over a probe grid atbwa_start, so the sim thread looks it up instead of ray-tracing live (static scenes only, which the bed requires anyway).bwa_set_reverb_gainis the wet level: the one control, live (a single atomic the audio-thread tap reads), default 1. A change ramps across one block, like every gain. A value set beforebwa_startseeds whichever reverb bed it creates (this bed or the FDN).bwa_source_set_reverbopts a source into the bed's wet send. Per-frame, non-blocking; with the bed disabled (or no SDK) it gates a send that goes nowhere.bwa_source_set_reverb_sendsets that source's send level (default 1.0). Drive it yourself for a manual dry/wet.bwa_source_set_reverb_distanceadds automatic distance→wet scaling on top: near = drier, far = wetter.
The effective send is computed and ramped on the audio thread (in rt.c, from the
source↔listener distance), so motion and on/off toggles never zipper the send.
typedef struct {
int enabled; // 0 = no FDN created
float rt60_low_s, rt60_high_s, xover_hz; // 0 -> defaults 1.2 / 0.7 / 2000
float decay_dir[3], decay_factor; // anisotropy; zero dir or factor 0/1 = uniform
uint32_t reserved[3];
} bwa_fdn_desc;
void bwa_fdn_config(bwa_engine* e, const bwa_fdn_desc* cfg); // LOAD-TIME
void bwa_fdn_set_decay(bwa_engine* e, float rt60_low_s, float rt60_high_s, float xover_hz); // LIVEA phonon-free late-reverb alternative that takes the reverb tap instead of the Steam bed
(one reverb bed at a time; with the FDN enabled the Steam bed is skipped). It consumes the same
mono aux send, so bwa_source_set_reverb and the per-source send levels apply unchanged, and
bwa_set_reverb_gain sets its return level live.
Inside: a 16-line feedback delay network whose feedback matrix loses no
energy on its own, so the decay filters set the whole decay. Each line is assigned a direction on the sphere and rendered as a plane
wave through the layout's SH→speaker bed decode. rt60_low_s/rt60_high_s/xover_hz set a
two-band decay (defaults 1.2 s low / 0.7 s high at 2 kHz). decay_dir + decay_factor make the
decay anisotropic: the field dies faster (factor < 1) or slower toward that direction, the
diagonal special case of the Directional FDN (Alary/Politis/Schlecht, JAES 2019). Use it to
design a space (an open side, a treated wall); do not match the real room's RT60 (see
calibration.md).
The decay is live. bwa_fdn_set_decay retunes the two-band decay while the engine runs: the
FDN ramps its per-line loss gains to the new values across one block (~5 ms), so the tail keeps
ringing and only its slope changes: no click, no restart. This is what a room transition sounds
like: stepping into a cathedral, submerging (long low band, dead high band), walking out into open
air. <= 0 keeps a parameter's current value; pre-start it just updates the staged config, so a
scene can call it unconditionally. Structure (enable, anisotropy) stays load-time; the wet level is
bwa_set_reverb_gain, as always.
Deterministic CPU (no rays, no IRs, infinite tail), and it works in no-SDK builds, so reverb does not require the Steam Audio SDK.
void bwa_scene_set_box(bwa_engine* e, float w, float h, float d, const bwa_material faces[6]); // the room
void bwa_scene_set_ground(bwa_engine* e, float y, bwa_material mat, bool pressure_release); // or a bare plane
void bwa_scene_set_pressure_release(bwa_engine* e, uint32_t face_mask); // flag inverting faces (water)
void bwa_source_set_early_reflections(bwa_engine* e, bwa_source s, bool on); // per source; per-frame-safe
void bwa_set_early_reflections_gain(bwa_engine* e, float linear); // live; default 1The other half of the phonon-free acoustics path. The FDN renders the late diffuse tail; this
renders the six first-order specular reflections, each a real point source at its mirrored
position, panned through the engine's own listener-relative panner: correct direction and
parallax as the listener walks, which no shared listener-centric bed can give
(spatialization.md).
Path delay, distance attenuation, and per-band wall absorption fall out of the geometry; delays
glide and gains ramp, so a moving source bends its reflections instead of stepping them.
bwa_scene_set_box captures the shoebox whether or not the Steam build is present.
Order 1 only, by design. Higher orders blend into the diffuse field within tens of milliseconds, which is exactly what the FDN already renders, for free and with a proper decay. A source outside the room renders dry.
Two room variants beyond the box: bwa_scene_set_ground renders just the ground bounce
(outdoor scenes: one plane, one image, mirrors a source on either side), and
bwa_scene_set_pressure_release flags faces that reflect inverted: a water surface seen from
below has reflection coefficient ≈ -1, so the flipped image cancels the direct sound near the
boundary (the Lloyd's-mirror comb). See "Materials and scene geometry" for both, and the
physical-emulation recipes (under "How-to guides") for the underwater assembly.
Cost: six panner solves per opted-in voice per block (one for the ground plane), plus the delay taps per sample. Opt in on the sources that matter (a few), not on everything.
bwa_sound, bwa_source, and bwa_bed are opaque uint32_t = (index | generation<<16). A stale
handle (slot destroyed, then reused) fails the generation check on the audio side and is
silently dropped rather than crashing. 0 is always invalid. Treat handles as tokens; never
do arithmetic on them. The one deliberate deviation: bwa_scene_add_dynamic_mesh returns a plain
small int index (-1 = failure); movers are few and app-managed, so they skip the
generation machinery.