Skip to content

Latest commit

 

History

History
3020 lines (2538 loc) · 199 KB

api.md

File metadata and controls

3020 lines (2538 loc) · 199 KB

bw_audio: usage guide and C ABI reference

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.

Feature overview

  • 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-speaker BWA_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 to BWA_EXTRA_LIS extra 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's bed_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 is bwa_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_ns reads 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 AutoEq ParametricEQ.txt and 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, and bwa_bed_gains_layout, which grades a layout file's own decode, imported matrix included, and bwa_bed_decode_matrix, which reads that decode back as a matrix for comparison with external tools).

Quickstart

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, with cfg.layout_path pointing at the surveyed cave_layout.json. A named layout that fails to load fails bwa_start with BWA_ERR_LAYOUT (a wrong-channel-count session can't start silently); NULL opts into the default grid (BWA_DEFAULT_GRID, 26 speakers) deliberately.
  • bwa_start never demands hardware under BWA_SINK_AUTO: with no usable device the engine keeps rendering into a silent offline sink. bwa_get_audio_backend reports which backend you actually got (see Errors).
  • Completion is an event, and also a poll: bwa_poll_ended drains the handles whose voices finished since you last asked. Prefer it. bwa_source_is_playing is 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.

API tiers: core and convenience

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).

Profiles and the master bus

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.

Recommended settings per setup

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.

The threading contract

  • 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_create may allocate or do I/O. The per-frame loop is pure enqueue.
  • bwa_commit defines 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.

Coordinates and units

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.

How-to guides

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).

Put a moving source in a room

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_config alone here. The Steam reflection bed also renders early reflections; running it with the ISM plays them twice (the engine warns once through bwa_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, drive bwa_source_set_occlusion_manual from game logic.
  • Wet levels: bwa_set_reverb_gain and bwa_set_early_reflections_gain are live, default
    1. Opt in the few sources that matter: each opted-in voice costs six panner solves per block.

Land a sound on a visual event

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_s is 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_at degrades to pairing bwa_get_dsp_time_frames with 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_frames crosses start + cue, or off bwa_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_model for 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 a ClockBridge that does the sandwich for you and exposes dsp_at, time_at, play_at and 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.

Develop at the desk, run on the rig

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_pose per 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.sink where 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_BOTH is the rig plus a monitor tap: the array over Dante and headphones at the operator's desk at once (the tap is the CAVE_SIM audition). It opens two devices, and sink, device and sink_flags describe 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 explicit BWA_SINK_NULL or BWA_SINK_MANUAL, which the monitor follows so an offline render stays offline.

Recipes: physical emulation

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.

The listener submerges

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).

The water surface overhead (Lloyd's mirror)

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.)

A doorway to the next room

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.

Wind

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);

Slow motion

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.

At arm's length

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) */

Lifecycle

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_SIM and CAVE_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

Errors and return codes

The API reports failure three ways, all read on the control thread:

  • Pointer/handle returns. bwa_create returns NULL on failure; bwa_load_sound / bwa_source_create return 0.
  • bwa_result returns. bwa_start, bwa_stop, bwa_tracker_connect and bwa_load_headphone_eq return BWA_OK (0) on success, a nonzero code otherwise: the bwa_result enum in include/bw_audio.h, listed below.
  • bwa_last_error. A human-readable string for the most recent failure on that engine, or NULL if 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 it NULL. 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 next bwa_* 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_create returns NULL for a NULL desc or an allocation failure. No code. It does not validate the desc fields: zero sample_rate and block_size take their defaults, and nothing else is checked.
  • bwa_start returns BWA_OK, BWA_ERR_CONFIG (NULL engine, or a room_eq layout in a moving-listener session), BWA_ERR_LAYOUT (below), or BWA_ERR_DEVICE. A redundant bwa_start is a no-op returning BWA_OK.
  • bwa_stop returns BWA_OK, or BWA_ERR_CONFIG for a NULL engine.
  • bwa_tracker_connect returns BWA_OK, BWA_ERR_CONFIG (NULL args / a static-room_eq layout), or BWA_ERR_TRACKER.
  • bwa_load_headphone_eq returns BWA_OK, or BWA_ERR_CONFIG (NULL engine, or a file that fails to read or parse; the previous EQ stays).
  • A failed explicit layout_path leaves bwa_create usable (the engine sits on the default grid; the reason is in bwa_last_error, readable right after create) but fails bwa_start with BWA_ERR_LAYOUT: a session that named a layout never silently runs the default grid's channel count and geometry. layout_path = NULL means the default grid deliberately.
  • A bad hrtf_path does not fail bwa_start. The monitor degrades to the simple pan and records why in bwa_last_error; if your session depends on a SOFA HRTF, read bwa_last_error after a successful bwa_start to 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.

Environment variables

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.

Ray-tracing acceleration (bwa_desc.embree)

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.

Sink policy

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.

Latency classes

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.

Device query (control thread, no engine needed)

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.

Assets (control thread, file I/O)

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 bed

Load 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.

Shared ownership: bwa_sound_acquire / bwa_sound_release

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.

Probing without loading: bwa_sound_find

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.

Async loading: bwa_sound_acquire_async

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.

Testing the held window

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.

Sources (control thread, non-blocking)

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 = dropped

The 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_sample you passed to play_at, and fire the visual when bwa_get_dsp_time_frames crosses start + cue. Or poll bwa_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 the ASIOGetSamplePosition pair; on the null sink it is the platform's monotonic clock.

The graphics-side recipe:

  1. 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 pairing bwa_get_dsp_time_frames with your own clock read.
  2. Measure the epoch offset. host_time_ns sits on a backend-defined epoch. bwa_host_time_ns reads 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(); gives offset = h - (a+b)/2, with the error bounded by (b-a)/2.
  3. Subtract the output latency. The dsp clock stamps when a block is rendered, not heard. bwa_get_output_latency_frames is 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 at T + 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_clock returns false with the outputs untouched until a host-stamped block has rendered, that is, before bwa_start.
  • On ASIO the host time is on QPC whatever clock the driver's systemTime uses. 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_paused gates 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_seek jumps 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 a bwa_sound_acquire_async decode, the seek rides with that play and lands with it.
  • Streamed sounds ignore seeks: the stream ring can't jump. bwa_source_play always 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_frame and reports through bwa_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));

Procedural (push) sources

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 drained

Engine-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_push returns the count accepted (pace with bwa_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_end marks 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_out end it the same way (the unconsumed remainder is dropped); use bwa_source_set_paused to silence one temporarily. bwa_source_destroy releases 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.

Master gain and global pause

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); live

bwa_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.

Ambisonic beds (control thread)

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 frames

bwa_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 on

bwa_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.

Listener (control thread; skip when a tracker is connected)

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.

Internal tracking (bwa_tracker_connect)

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).

Tracker status

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 blocks

A 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.

Pose prediction

void bwa_set_pose_prediction(bwa_engine* e, float lead_s);    // 0 = off (default); live

The 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).

Extra listeners (multi-occupant compromise)

#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 clears

A 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).

Reading back the pose

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.

Frame boundary

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.

Materials and scene geometry (control thread; static at load, dynamic per-frame)

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_box builds a floor-based shoebox with inward-facing normals: x/z centered on the origin, y from 0 (the floor) up to h; the listener stands inside.
  • The outdoor degenerate: bwa_scene_set_ground(e, y, mat, pressure_release). One horizontal mirror plane at height y instead 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_release flips 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,+z order) 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_box or _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_box replaces the static mesh. It is a bwa_scene_set_mesh_mat call plus the ISM capture. Calling _set_box and 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, use bwa_scene_set_ism_room and bwa_box_mesh; see Composing a scene.
  • Static versus dynamic. bwa_scene_set_mesh_mat/_set_box set the STATIC world: committed once, BVH built once. For things that MOVE, bwa_scene_add_dynamic_mesh registers a rigid instance (its own sub-scene) that you reposition with bwa_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. add returns a handle (or -1: no SDK / bad geometry / full).
  • 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_box followed immediately by bwa_start bakes 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_STEAMAUDIO build, except token minting (plain table state), the pure bwa_box_mesh, and the image-source room calls: bwa_scene_set_box and bwa_scene_set_ground ALWAYS capture the room for the early reflections, and bwa_scene_set_ism_room and bwa_scene_set_pressure_release touch nothing else. bwa_scene_add_dynamic_mesh returns -1 without the backend.
  • The room calls are live-safe. bwa_scene_set_box / _set_ground / _set_pressure_release publish 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.

Composing a scene out of the box and your own geometry

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.

Occlusion and directivity (control thread; per-frame except where noted)

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.

Manual occlusion (no SDK needed)

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.

Propagation effects (control thread; per-frame)

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; live

Opt-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).

Source spread / size (control thread; per-frame)

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/B

Angular 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/B

LOBE 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); live

Near-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); live

Hole-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".

Source configuration (bwa_source_preset, bwa_source_apply)

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 to

Configuring 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.

What each preset rests on

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:

  • PROP proximity, 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.
  • PROP and VOICE occlusion, reverb, reverb distance: intent. A real object is occluded by real geometry and excites the room, wetter far and drier near.
  • VOICE drops air absorption and proximity: intent. Both trade intelligibility for realism, and a talker rarely gets a walk-up cue.
  • VOICE and UI priority 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.
  • AMBIENCE spread 1: the ABI's documented wide endpoint. Dial it down, or use size_m for 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.
  • UI attenuation override: rolloff 0 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.

Channel test / diagnostics (control thread)

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 line

bwa_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.

Direct output-channel routing (the reference condition)

#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.

Device health: was the callback starved?

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 form

Everything 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:

  • xruns is 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_frames is how much audio those gaps swallowed.
  • late_blocks is us overrunning the block period, which is what eventually produces xruns. Fix downstream: a cheaper scene, fewer voices, and watch peak_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 is Date.now, whole milliseconds, so read it in ms there rather than as a percentage of a 2.7 ms period.
  • stream_starves is 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_lost is 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. Call bwa_stop then bwa_start to 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.

Output capture (recording / golden checks)

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).

Offline / deterministic render: bwa_render_block

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.

Panner and layout query (control thread)

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 only

bwa_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's bed_decode block. The Ambisonic Decoder Toolbox designs it (two-band rV/rE-matched decoders, mixed-order decoders for sparse vertical coverage), writes an AmbDec preset, and tools/decoder/ambdec_to_json.py converts 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, full1 to full3; with no exact match, the nearest full order, higher first, and a one-time note in bwa_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_create still succeeds (reason in bwa_last_error) and bwa_start refuses with BWA_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.

Channel count

#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 create

The 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.

Offline panner and bed evaluation

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.

  • channels names 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_re applies the taper by the bed's highest order. It has no effect on an imported matrix, as in the engine.
  • band 0 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 = NULL is 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 about dirs, ndir and out_cap, so it returns 0, with the same reason, for a file the full call would refuse (for example BWA_DECODE_LAYOUT on a file with no bed_decode). Otherwise out needs ndir*N floats and out_cap says 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 channel k to speaker index s, 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 channels are 0, and the max-rE taper is folded in, so out times the SN3D encode of a direction is exactly what bwa_bed_gains_layout returns for it.
  • out = NULL is the same size query. Otherwise out_cap must be at least N*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.

Tracked room EQ (control thread; live)

void bwa_set_tracked_room_eq(bwa_engine* e, bool on);   // default ON when the layout carries a grid

Layouts 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.

Tracked directivity compensation (control thread; live, ON by default)

void bwa_set_tracked_directivity(bwa_engine* e, bool on);   // default ON when the layout carries a model

Layouts 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.

Tracked listener alignment (control thread; live, OFF by default)

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.

Situation tuning (bwa_tuning_preset, bwa_apply_tuning)

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 at

bwa_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.

What each field rests on

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.

Output protection limiter (control thread; ON by default)

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).

Headphone correction EQ (control thread)

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 on

The 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.

Reflection bed (control thread)

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 = wetter

A 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_config must land before bwa_start: the IR length and ambisonic order are baked there. Zero fields take defaults (ir_seconds 1.0, order 1, num_rays 4096, num_bounces 16). enabled = 0 creates no bed; the engine behaves exactly as without one. bake non-zero precomputes the reverb over a probe grid at bwa_start, so the sim thread looks it up instead of ray-tracing live (static scenes only, which the bed requires anyway).
  • bwa_set_reverb_gain is 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 before bwa_start seeds whichever reverb bed it creates (this bed or the FDN).
  • bwa_source_set_reverb opts 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_send sets that source's send level (default 1.0). Drive it yourself for a manual dry/wet.
  • bwa_source_set_reverb_distance adds 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.

Directional FDN reverb (no SDK needed)

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);  // LIVE

A 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.

Image-source early reflections (no SDK needed)

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 1

The 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.

Handle scheme

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.