AmbiRR2

July 10, 2026 · View on GitHub

This document describes the full DSP implementation of AmbiRR2: the ambisonic conventions and maths, the room model, the split between early reflections and the late diffuse tail, the crossfading rules that tie them together, the two tap-update smoothing modes (crossfade vs. Doppler), the realtime listener rotation, and the threading model that keeps all of it off the audio thread.

Code references are given as File :: symbol. The generic, reusable maths lives in the FxmeTools shared library (lib/FxmeTools/FxmeTools/dsp/); the plugin-specific orchestration lives in Source/Dsp/.


1. Overview and signal flow

AmbiRR2 is a 16-in / 16-out plugin. Each of the 16 input channels drives one mono source placed in a parallelepipedic (box) room; the output is a 3rd-order ambisonic stream in AmbiX format.

 in 1..16 (mono sources)

    ├─────────────────────────────────────────────┐
    │                                              │ Σ active sources
    ▼                                              ▼
 per-source multitap delay line             mono diffuse send
 (direct + early reflections,                      │
  image-source taps, one-pole                      ▼
  HF damping per tap, SN3D                 WDL convolution with 16
  encoding gains per tap)                  decorrelated decaying IRs
    │                                     (one per ambisonic channel)
    │  16-ch ambisonic (room frame)                │ 16-ch ambisonic
    └────────────────────┬─────────────────────────┘

              Σ  +  listener rotation
            (SH rotation matrix, per block,
             cross-faded — room ➜ head frame)


                out 1..16 (AmbiX ACN/SN3D)

The order dropdown doubles as the output selector: its entries are the three ambisonic orders plus a stereo two-microphone mode (§2.6) in which the taps carry per-mic scalar gains instead of SN3D encodings and the rotation stage is bypassed.

Two independent computation paths feed the realtime renderer:

Early fieldLate field
Modelimage-source methodstatistically diffuse field
Realtime unitmultitap delay line per sourcepartitioned convolution (WDL)
Recomputed whena source, the listener or the room changesthe room changes only
Position dependencefull (per source, per listener position)none (shared by all sources)
Listener orientationnot baked in — applied later by rotationnot baked in (isotropic)
Automatableyes (§5.4)n/a — it never moves

That second row is the whole automation story: nothing positional ever reaches an impulse response, so listener and source positions can be automated (§5.4).

Constants (Source/Dsp/AmbiDefs.h): order 3 → 16 channels, 16 sources, room 1–20 m per edge, Taps target 1–512 (default 64; the rendered count varies, §5.3), speed of sound 340 m/s, late tail capped at 8 s, early delay lines sized for 2 s.


2. Ambisonic implementation

All ambisonic maths is in FxmeTools/dsp/Ambisonics.h (header-only, JUCE-free).

2.1 Conventions

  • Format: AmbiX — ACN channel ordering, SN3D normalisation.
  • Frame: right-handed, x = front, y = left, z = up.
  • Angles: azimuth = yaw (CCW about +z, 0 at +x), elevation = pitch (positive up); radians internally, degrees at the parameter level.
  • Channel index: acn = l(l+1) + m for degree l, order m.

2.2 Encoding

Ambisonics.h :: encodeSN3D(dir, gains, order) evaluates the real spherical harmonics up to degree 3 directly as polynomials of the unit direction (x, y, z) — no trigonometry at encode time. E.g. degree 1 is (Y,Z,X) = (y, z, x); degree 2 includes √3·xy, (3z²−1)/2, (√3/2)(x²−y²), etc. The W channel (ACN 0) is 1.

Each early-reflection tap stores its 16 encoding gains premultiplied with its broadband gain and crossfade factor, so the realtime cost per tap is 16 multiply-accumulates per sample and nothing else.

2.3 Runtime order selection

The output order (1/2/3) is a dropdown parameter. Lower orders simply render into the first (order+1)² channels; the rest stay silent. The recompute uses the same limit for the late IRs (fewer channels to generate), and the rotation stage rotates only whole degrees that fit in the active channel count.

2.4 Listener rotation (SH rotation matrices)

Head rotation must not trigger a recompute. Therefore:

  1. Everything (taps and IRs) is computed in the room frame.
  2. The listener's (azimuth, elevation) is turned into a 3×3 rotation whose rows are the head's forward/left/up axes in room coordinates (Ambisonics.h :: headRotation; a roll argument exists but is not yet exposed as a parameter).
  3. That 3×3 matrix is lifted to a real-SH rotation matrix with the Ivanic–Ruedenberg recurrence (J. Phys. Chem. 1996, with the 1998 corrections): degree 1 is a re-indexed copy of the 3×3 matrix (ACN degree-1 axis order y, z, x), and each higher degree is built recursively from degree 1 and degree l−1 via the u/v/w–U/V/W terms (Ambisonics.h :: ShRotation::fromMatrix).

Properties exploited:

  • The matrix is block-diagonal per degree (rotations never mix degrees), so applying it to a 16-channel sample frame costs 3² + 5² + 7² = 83 multiplies (ShRotation::apply); W is passed through.
  • Per-degree rotation blocks are identical in SN3D and N3D (the normalisation differs only per degree), so no conversion is needed.
  • The construction satisfies encodeSN3D(M·d) == ShRotation::fromMatrix(M) .apply(encodeSN3D(d)). This was verified numerically against direct re-encoding over 2000 random rotations (max error ≈ 8·10⁻⁷), plus orthogonality of every block and exactness of the identity rotation.

Runtime behaviour (AmbiRoomEngine.cpp :: process, setListenerOrientation):

  • The message thread rebuilds the matrix when azimuth/elevation change (microseconds) and publishes it through a lock-free single-slot mailbox.
  • The audio thread consumes it and cross-fades from the current matrix to the new one linearly over exactly one block: during that block both matrices are applied (2 × 83 multiplies) and the outputs interpolated per sample. This makes head moves click-free; at the 30 Hz parameter push rate successive matrices are close, so linear interpolation of the outputs is perceptually transparent.
  • Rotating the late tail with the same matrix is harmless: the diffuse field is isotropic by construction, and rotation preserves per-degree energy.

2.5 Diffuse-field order weighting

For an isotropic diffuse field in SN3D, channel energy must scale as 1/(2l+1) per degree. The late-reverb IRs are therefore weighted by diffuseFieldOrderGain(l) = 1/√(2l+1) (Ambisonics.h), which keeps the perceived reverb level stable across output orders and consistent with the encoded early field.

2.6 Stereo mode: two virtual microphones

The stereo output mode replaces the ambisonic encoding with two virtual first-order microphones at their own reception points, rendered to channels 0 (L) and 1 (R):

  • Polar patterns (Ambisonics.h :: micGain / micPatternAlpha): the classic first-order family g(θ) = α + (1−α)cos θ, with α = 1 (omni), 0.5 (cardioid), 0.366 (supercardioid), 0.25 (hypercardioid), 0 (figure-8). θ is the angle between the mic axis (from the per-mic azimuth parameter; the maths supports elevation but only azimuth is exposed) and the arrival direction. Gains are signed — the rear lobe of tight patterns and of the figure-8 is a genuine phase inversion, preserved per tap.
  • Per-mic image sources (ImageSourceEngine :: computeStereoTaps): the image-source enumeration runs once per microphone position, so a spaced pair gets true inter-mic time-of-arrival and level differences (AB / ORTF / XY behaviour emerges naturally). Each mic gets the full tap budget — the crossover is a property of the room, not of the output mode — and the two lists are concatenated into the source's single TapSet, each tap carrying its scalar gain (reflection losses × 1/r × pattern × crossfade) in gain[0] or gain[1]. The whole downstream pipeline — mailbox handoff, crossfade and Doppler smoothing (§6) — is unchanged and simply runs with 2 active channels; tap ids are offset per mic (id | m<<20) so Doppler matching still works.
  • Late tail: two decorrelated unit-weight IRs (the diffuse field reaches the two mics essentially uncorrelated; pattern-dependent diffuse sensitivity is neglected as a second-order effect).
  • Rotation bypassed: listener orientation is meaningless for a mic pair; the rotation stage is skipped (the matrix is kept in sync for a later switch back to ambisonic).
  • Per-mic volume: output gains ramped per sample across each block.
  • Mid/Side (MicMS): after the per-mic level trims, an optional in-place unity-gain matrix sets channel 0 = ½(L + R) (mid) and channel 1 = ½(L − R) (side), for M/S recording or width control downstream.
  • The early↔late crossover window is the union (min start, max end) of the two mics' windows.
  • Switching modes marks the geometry dirty, so the taps and IRs rebuild immediately; the swap itself is smoothed like any other recompute.

3. Room model (image-source method)

The room model is in FxmeTools/dsp/RoomAcoustics.h (header-only, JUCE-free).

3.1 Image enumeration

For a box [0,rx]×[0,ry]×[0,rz], the image of a source at s for grid index (ix, iy, iz) is, per axis (computeImageSources):

x_img = 2·ceil(ix/2)·rx + (ix odd ? −sx : sx)      (idem y, z)

The enumeration bound is always a radius in metres, converted per axissteps(dim) = ceil(r/dim) + 1 — never a cube of grid indices. The image lattice has period (2rx, 2ry, 2rz), so it inherits the room's aspect ratio, and a cube of indices badly under-samples the thin axis of a flat or tall room. The radius comes from whichever truncation policy is in force (ImageSourceConfig):

  • maxDelaySamples > 0time-bounded: r = c · t. Every image arriving before t is kept (maxTaps acting only as a safety cap). Used by the plugin, because the truncation time must be position-independent so it can line up with a shared, static late-reverb IR (§5.3).
  • otherwise — count-bounded: the sphere holding n images has radius r = ∛(3nV/4π) (image density is 1/V); enumerate 1.35·r so the truncation to n survives the minGain culling. Candidates are sorted by arrival and truncated to maxTaps.

3.2 Per-image acoustics

For each image:

  • Delay = round(dist / c · sr) samples (dist floored at 0.1 m).
  • Wall hit counts: reflections along one axis alternate between that axis's two walls; which wall is hit first depends on the sign of the index. From |ix| and sign(ix) the code derives exact per-wall counts n_w for all six walls.
  • Broadband gain = Π_w (1 − damp_w)^{n_w} / dist — per-wall amplitude damping and spherical spreading. Images with gain < 10⁻⁵ are skipped (the direct tap is always kept).
  • HF damping: each wall hit adds its hfDamp_w to an accumulated sum H; the tap gets a one-pole low-pass y[n] = a·x[n] + (1−a)·y[n−1] with a = 1 − exp(−ω/sr), ω = 2π·20000·e^{−H} (hfDampingAlpha). Zero HF damping puts the cutoff at 20 kHz (a ≈ 1, and the renderer skips the filter entirely when a ≥ 0.999).
  • Direction of arrival is stored as a unit vector in the room frame; the plugin folds it through encodeSN3D (§2.2). Listener orientation is not applied here (§2.4).
  • Stable id: the packed grid index (ix+16) | (iy+16)<<6 | (iz+16)<<12. The same physical reflection keeps its id across recomputes, which is what makes per-tap matching (Doppler mode, §6.2) possible.

3.3 Reverberation time

damp is an amplitude loss (images reflect with the factor 1−damp), so the fraction of energy absorbed per reflection is α = 1 − (1−damp)²not damp. RoomAcoustics.h :: energyAbsorption does that conversion, and both RT60 formulae take α:

sabineRT60   RT60 = 0.161·V / Σ(S_w · α_w)                accurate for small α
eyringRT60   RT60 = 0.161·V / −Σ(S_w · ln(1−α_w))
                  = 0.161·V / −2·Σ(S_w · ln(1−damp_w))    accurate everywhere

The plugin uses Eyring (clamped to [0.05 s, 8 s]), because the diffuse tail has to continue an image-source field whose energy decays exactly as (1−damp)^{2n}. t60DecayPerSample converts RT60 to the exponential rate k with e^{−k·n} falling 60 dB over RT60 (k = ln(10⁶)/(RT60·sr) in energy).

Bug history. Feeding damp straight into Sabine, as if it were already an energy coefficient, made RT60 too long by two compounding factors: the α conversion (1.8× at damp = 0.2) and Sabine-vs-Eyring at those α (1.24×). Measured against the image-source field's actual decay, the tail was 2.2–2.6× too slow across damp ∈ [0.1, 0.7] — it did not continue the early decay, it sat under it as a separate, slower event. With Eyring on the converted α the measured ratio is 0.81–1.14.


4. Early field: realtime multitap rendering

Source/Dsp/EarlyReflections.*.

  • One circular delay line per source, length = next power of two ≥ (2 s · sr + block + 4), indexed with a bit mask (no modulo).
  • Each block first writes the source input into the line, then reads every tap at writePos − delaySamples.
  • Per tap: optional one-pole HF filter (skipped when transparent), the direct-vs-reflections level, then 16 MACs into the ambisonic bus using the premultiplied encoding gains. Channels above the active order are untouched.
  • The direct tap (grid index 0,0,0) is flagged and receives the "Direct" level; all other taps receive the "Early" level. Both levels are smoothed (§7).

Worst-case cost scales as taps × active sources × channels; the defaults (64 taps) put typical usage far below the 512-tap ceiling.


5. Late field: diffuse convolution tail

Source/Dsp/LateReverb.*.

5.1 Model

Following the design brief, the long reverberation is approximated as a position-independent diffuse field (valid above the Schroeder frequency): a single set of 16 IRs — one per ambisonic channel — shared by all sources. The convolution input is the mono sum of the active sources, fed identically to all engine channels; decorrelation between output channels comes entirely from the IRs.

Sharing one position-independent IR is not a compromise, it is the physically correct model above the Schroeder frequency:

  • In a diffuse field the reverberant level follows the source's power, not its distance — so a distant and a near source should, and do, drive the same reverb.
  • The direct-to-reverberant ratio still varies correctly with distance, because the early taps carry 1/r while the reverb send does not.

Positional detail in the reverb is therefore not bought by making the IR position-dependent; it is bought with the tap count. Raising Taps extends the deterministic image-source field, which is exact, per-source, directional, distance-correct, Doppler-capable, smoothed — and automatable. The IR only has to cover the genuinely diffuse remainder.

The one thing this model gives up is per-source reverb decorrelation: with a shared IR and a mono sum, every source's tail is the same realisation. If that ever matters, the cheap fix is a per-source decorrelating allpass on the send, not a per-source IR.

5.2 IR construction (background thread)

Per channel ch of the active order (rebuild):

  1. Noise: white noise from a deterministic per-channel seed (so the 16 channels are reproducibly decorrelated), shaped by a one-pole low-pass whose cutoff follows the average HF damping: f_c = clamp(18000·e^{−6·hfAvg}, 500, 18000) Hz.
  2. Envelope: e^{−k·i} with k from Eyring (§3.3), referenced to absolute time i = 0not to the crossover (§5.3).
  3. Fade-in window: silent before crossStart, then √(w(2−w)) up to crossEnd, unity after (§5.3).
  4. Amplitude: physical, not a normalisation of convenience. An image shell at radius r = ct, thickness c·dt, holds 4πr²c·dt/V images (image density is 1/V) each of energy (refl/r)², so the early field's energy density is 4πc·refl²/V per unit time — i.e. 4πc/(V·sr) per sample at t = 0. The noise is uniform[−1,1) (variance ⅓) through a one-pole low-pass of steady-state variance var·a/(2−a), so norm² = 4πc / (V·sr·varLp_ref), times the per-degree diffuse weight 1/√(2l+1) (§2.5). varLp_ref is taken at the transparent filter (hfDamp = 0), so raising HF damping removes the high end and makes the tail quieter, rather than boosting the low end to hold total energy fixed. Verified against the measured image-source density: ±1.2 dB (mostly ±0.5 dB) across 18–4320 m³ and 64–512 taps.
  5. IR length = min(8 s, max(RT60, crossEnd+1)) samples.

Bug history. This used to be norm = √(2k), the analytic unit-total- energy factor of a full e^{−k·i} noise tail. That makes the diffuse energy density proportional to k ∝ A/V, i.e. to the absorption area — so measured against the early field it is supposed to continue, the tail ran from −12.6 dB (tiny room) to +2.6 dB (huge room), a 15 dB drift. The comment claiming the level "does not jump around with room size or RT60" described the IR's total energy, which is not what the ear hears at the crossover.

The 16 channels are generated in parallel on the worker pool, then loaded into Cockos' WDL_ConvolutionEngine_Div (the same engine as the original BiRR — a partitioned FFT convolver with cheap per-block cost).

5.3 Early ↔ late crossfade

Source/Dsp/Crossover.h owns one window [crossStart, crossEnd] that both the tap engine and the IR use. That single source of truth is the whole design:

early taps :  gain 1 before crossStart, linear to 0 at crossEnd,
              and truncated by TIME at crossEnd  (RoomAcoustics :: timeCrossfadeGain)
diffuse IR :  silent before crossStart, √(w(2−w)) up to 1 at crossEnd

Those two are power-complementary: (1−w)² + w(2−w) = 1. The taps and the noise are uncorrelated, so their energies add, and the sum is unity at every instant — no hole, no bump, at any placement. (A matching linear w fade-in would leave (1−w)² + w² = ½ at the midpoint, a 3 dB dip.)

Two constraints fix the window, both from the room alone — never from a position, which is what keeps the IR static and positions automatable (§5.4):

  • The tap target sets the time. Taps = N is a target image count, evaluated once at a canonical reference geometry; the arrival of the N-th tap there gives crossEnd, and crossStart = 0.9 · crossEnd.
  • The crossover can never precede the direct sound. A source at the far corner has its direct path arrive at diagonal / c. If crossStart were earlier, that direct path would be faded away — impossible, since the diffuse field is caused by it. So the window is pushed out until crossStart ≥ 1.05 · diagonal / c. (Measured: without this clamp, a tall room at 16 taps has zero taps before crossEnd for distant placements.)

Because the taps are truncated by time, their count varies with room shape and placement — measured 1.0×–1.5× the target at 256–512 taps, more at low targets where the diagonal clamp dominates. maxTapsPerPoint carries the headroom (worst case measured: 572).

The key invariant: the diffuse envelope is anchored to t = 0. Changing the tap count therefore only moves the crossover — which mechanism renders a given time range (discrete taps vs. faded-in diffuse) — and never moves, recolours or relevels the tail itself.

Bug history, three layers deep.

  1. crossEnd used to be max(lastTap, crossStart + 20 ms). The taps' fade-out spans only 1–3 ms, so that floor always won and the diffuse reached full level ~19 ms after the last tap died — a hole in every room.
  2. Replacing it with the closed form r = ∛(3nV/4π), t = r/c was wrong twice over. It ignored that computeStereoTaps halved the tap budget per microphone, leaving 50 ms of silence at Taps = 512 in stereo (and none in ambisonic — which is exactly how the bug was reported). And it assumes a spherical image set while computeImageSources enumerated a cube of grid indices, so in a flat room the diffuse fired 25 ms too early.
  3. Even with the window taken from the real enumeration, the join could not be made exact: the taps faded by tap index (position-dependent) while the IR faded by absolute time (position-independent, as automation demands). Measured over random placements: −70 dB holes in anisotropic rooms and a +3 dB bump whenever the real taps outlasted the window. No choice of window fixes that — only fading the taps by time, over the same window, does.

The enumeration is now radius-derived per axis in both its time-bounded and count-bounded forms, because the image lattice inherits the room's aspect ratio: a cube of indices under-samples the thin axis of a flat room. Fixing only the time-bounded path left the reference window wrong, which inflated the tap count 4× in a 20×20×2 slab.

5.4 What can be automated (and what cannot)

Anything that rebuilds an impulse response is off-limits for automation: an IR rebuild is a background job of tens of milliseconds to seconds, it cannot keep up with a control-rate parameter stream, and each rebuild swaps the convolver (a discontinuity the tap smoothing of §6 does not cover — it smooths taps, not IRs).

The boundary is exactly the RoomParams predicate that a change breaks:

ChangeRebuildsAutomatable
Listener position X/Y/Ztaps (all sources)yes
Source positionsthat source's tapsyes
Mic positions / azimuth / patterntaps (all sources)yes
Listener azimuth / elevationnothing (realtime SH rotation, §2.4)yes
Levels, mic levels, MS, Doppler, linksnothingyes
Room dimensionstaps + the late IRsno
Wall damping / HF dampingtaps + the late IRsno
Tap counttaps + the late IRs (it sets the crossover)no
Order / output modetaps + the late IRs (channel count)no

So: listener and source positions are fully automatable; room dimensions and damping recalculate the IR and can never be automated. Set the room once per scene, then automate everything that moves inside it.

Tap rebuilds are on the automation path, and that is fine: they are cheap, per-source, run on the worker pool, and are handed to the audio thread through a lock-free mailbox and cross-faded (or Doppler-glided, §6) on arrival.

5.5 Realtime side

process try-locks a spinlock around the engine; if the background thread is mid-SetImpulse, the tail is skipped for that block instead of stalling the audio thread. The reverb return level is ramped per sample across each block (addFromWithRamp), so fader moves don't step.


6. Tap-update smoothing: crossfade vs. Doppler

Moving a source (or the listener, or a wall) triggers a background recompute whose result replaces the live tap set. A naive swap is a waveform discontinuity in every tap → clicks. Two smoothing modes exist (EarlyReflections.cpp), selected by the Doppler parameter:

6.1 Crossfade mode (default)

On arrival of a new set, the outgoing set — and its per-tap filter states — moves to a per-source fading slot, and for tapCrossfadeSeconds (20 ms) both sets are rendered from the same delay line with complementary linear per-sample ramps. The new set starts from cleared filter memory under its fade-in; the old set fades out with its filter memory intact, so neither end of the fade has a transient.

  • Choice of linear (not equal-power): old and new sets are strongly correlated (same source, slightly moved), so amplitude-complementary is correct; equal-power would bump.
  • 20 ms < the ~33 ms parameter-push interval, so fades never pile up.
  • Cost: one extra tap-set render per source, only during a fade.
  • Limitation: a fast continuous move is a chain of 30 fades/s between static snapshots — click-free but with a faintly granular character and no pitch shift.

The old/new pair is obtained race-free from the mailbox by a three-way swap (SpscMailbox::tryConsume(previousOut)): previous-live → caller, staged-new → live, all before the staging slot is released to the producer.

6.2 Doppler mode (optional switch)

Per-tap parameter interpolation with fractional delays — real source movement instead of snapshots:

  • Matching: the worker maps each new tap to its predecessor via the stable image id (§3.2) and stores Tap::prevIndex (ImageSourceEngine.cpp; hash-map matching happens on the background thread). Sorting/truncation does not break identity.
  • Render state (EarlyReflections :: DopplerTap): per tap, a current fractional delay and 16 current gains plus per-sample ramp steps toward the new targets, the one-pole coefficient and memory, and the direct flag.
  • Glide: on each consumed set, matched taps keep their current values and get steps (target − current)/rampLen with rampLen = dopplerRampSeconds = 40 ms — slightly longer than the update interval, so successive updates chain into one continuous glide. Newly appeared taps ramp their gains up from zero at their own delay; vanished taps are appended to a dying list (≤ maxDyingTaps = 256 per source; beyond that the already-quietest simply drop) and ramp to silence in place. The delay is snapped exactly to the integer target when a ramp completes; gain-step rounding (≤ 10⁻⁴ relative over a ramp) is retargeted away at the next update.
  • Fractional read: the delay-line read at writePos + n − delay splits the delay into integer + fraction and linearly interpolates the two neighbouring samples (the split keeps float precision independent of the buffer position). A sliding delay thus resamples the source — this is the physical Doppler shift: f' = f·(1 − v_radial/c) emerges naturally from the delay derivative, for every tap (direct and each reflection, each with its own radial velocity — including the correct opposite shifts of wall echoes).
  • Mode transitions are click-free both ways. Enabling: the first Doppler state is initialised from the outgoing static set and its filter states, so it glides rather than jumps. Disabling: a source keeps rendering in Doppler until its ramps are quiescent, then its filter states are folded back into the static path (state order matches the live set 1:1).
  • Cost: roughly 2× the static per-tap work (interpolated read + per-sample ramp of 16 gains) plus ~11 MB of preallocated per-tap state — the reason it is a switch. A non-moving source in Doppler mode pays only the interpolated read.

6.3 What is not interpolated

Recompute results are quantised in time by the 30 Hz parameter push; both modes reconstruct continuity on the audio thread. The one-pole HF coefficient is stepped (not ramped) per update — coefficient steps with preserved state are inaudible at these rates. In crossfade mode the integer tap delays remain quantised per snapshot (masked by the fade); only Doppler mode renders true inter-snapshot motion.


7. Level and parameter smoothing summary

QuantityMechanismWhere
Tap sets (source/geometry moves)20 ms linear crossfade or 40 ms per-tap glide (Doppler)EarlyReflections
Listener rotationnew SH matrix cross-faded over one blockAmbiRoomEngine::process
Direct / Early levelsper-block one-pole toward target (coef 0.35/block)AmbiRoomEngine::process
Reverb levelper-sample linear ramp across each blockLateReverb::process
Late IRs (geometry)hard swap under try-lock (rare; tail content is diffuse)LateReverb::rebuild

The late-IR swap is the one remaining hard transition; smoothing it would require running two convolution engines and crossfading (≈ 2× convolution cost during geometry changes). Geometry edits are rare enough that this has not been worth it.


8. Threading and realtime safety

Four kinds of threads touch the engine (AmbiRoomEngine.*):

  • Audio threadprocess() only. Never locks (except one try-lock that skips work), never allocates, never resizes. All per-tap and per-source state is preallocated in prepare().
  • Message thread — parameter push at 30 Hz (PluginProcessor:: timerCallback): copies the APVTS into POD snapshots, updates atomics, rebuilds the rotation matrix on orientation changes. Cheap and lock-light (one non-audio critical section shared with the worker).
  • Worker thread (juce::Thread) — woken on dirty flags; snapshots the request, fans per-source tap jobs out to the pool, waits, then rebuilds the late IRs only if the room changed (reverbDirty).
  • Thread pool (physical cores − 1) — one job per dirty source (per-source dirty flags mean dragging one source recomputes only that source), plus one job per IR channel during late rebuilds.

Handoff primitives:

  • SpscMailbox<T> (Source/Dsp/SpscMailbox.h) — single-slot, single-producer/single-consumer, one std::atomic<bool> with acquire/release ordering. The producer fills staging() only while the flag is down and publish()es by raising it; the consumer tryConsume()s by swapping live↔staging (pointer-cheap for vector payloads) and lowering the flag. The tryConsume(previousOut) variant also swaps the old live value out to the consumer before releasing the slot (§6.1). Used for tap sets (16×) and the rotation matrix.
  • Atomics — levels, active source count, active channel count, progress values, computing flag, Doppler switch.
  • requestLock (juce::CriticalSection) — guards the pending parameter snapshot between message thread and worker only; the audio thread never takes it. It carries three dirty flags: tapsDirtyAll (set when RoomParams::tapsEquals breaks), reverbDirty (when reverbEquals breaks — never by a position, §5.4), and a per-source sourceDirty[s].
  • Spin try-lock — around the WDL engine for the rare IR swap (§5.5).

Worker jobs wait for canProduce() (i.e. the audio thread has taken the previous set) with a sleep-poll before overwriting staging; the pool and the worker are both drained with timeouts on releaseResources()/destruction.

Progress for the UI is the mean of the 16 per-source atomics and the reverb build atomic (getProgress), displayed by ProgressMeter.


9. Memory and CPU budget

At 48 kHz, defaults in parentheses:

  • Delay lines: 16 × next-pow-2(2 s·sr) floats ≈ 8 MB.
  • Filter states: 16 × 2 × 512 floats ≈ 0.06 MB.
  • Doppler state: 16 × 2 × (512+256) × ~150 B ≈ 3.7 MB (allocated regardless of the switch so enabling it mid-session cannot allocate on the audio thread).
  • Late IRs: ≤ 16 × 8 s × sr floats ≈ 24 MB inside the convolver at the RT60 cap (typically far less).
  • Per-sample audio cost: taps × sources × (order+1)² MACs for the early field (64 × 1 × 16 ≈ 1k MACs/sample at defaults; the 512 × 16 ceiling is deliberately generous), 83 multiplies for rotation (166 during a rotation ramp block), and the FFT-partitioned convolution for the tail. Crossfades double the early cost transiently; Doppler roughly doubles it while anything moves.

10. Parameters

"Rebuilds" below names the heaviest thing a change triggers; Auto is whether the parameter can be automated (§5.4).

IDRange / valuesRebuildsAutoNotes
RoomX/Y/Z1–20 mtaps + IRsnobox dimensions
Damp<wall> ×60–0.99taps + IRsnoamplitude absorbed per reflection
HFDamp<wall> ×60–0.5taps + IRsnoextra HF loss per reflection
Taps1–512 (default 64)taps + IRsnotarget image count at a reference geometry; it sets the crossover time (§5.3). Taps are truncated by that time, so the rendered count varies 1.0–1.5x with room shape
Order1st/2nd/3rd/Stereo (choice)taps + IRsnochannel count and output mode (§2.6)
ListenerX/Y/Z0.01–0.99taps (all sources)yesfraction of each dimension
Src<n>X/Y/Z ×160.01–0.99taps (that source)yesper-source position
Mic<L/R>X/Y/Z0.01–0.99taps (all sources)yesmic reception points (stereo)
Mic<L/R>Az±180°taps (all sources)yesmic axis (stereo)
Mic<L/R>Patternomni…figure-8 (choice)taps (all sources)yespolar pattern (stereo)
Azimuth±180°nothingyesrealtime SH rotation only (§2.4)
Elevation±90°nothingyesrealtime SH rotation only (§2.4)
Sources1–16nothingyesactive inputs
DirectLevel / ReflLevel / ReverbLevel−90…+6 dBnothingyessmoothed (§7)
Mic<L/R>Level−90…+6 dBnothingyesper-mic output gain, ramped
MicMSon/offnothingyesmid/side: ch0 = ½(L+R), ch1 = ½(L−R)
Doppleron/offnothingyesper-tap glides vs. tap-set crossfade (§6)
LinkDamp / LinkHFDampon/offGUI gang: editing one wall writes all six
LinkMicson/offGUI gang: drags / azimuth apply as deltas to both mics

Parameters are change-detected against the last snapshot (RoomParams::tapsEquals / reverbEquals), so the 30 Hz push is free when nothing moved.


11. GUI notes

  • Controls are fxme::FxmeSlider + fxme::FxmeLookAndFeel (value read-out inside the knob, name below, right-click to type a value); one accent colour per control, centralised in Source/Theme.h.
  • Right column layout, top to bottom (PluginEditor::resized): a RoomPanel (a juce::TabbedComponent with a Dimensions tab — width / depth / height — and a Damping tab holding the WallPanel grid); the listener block, which shows the ambisonic ListenerPanel (X/Y/Z + azimuth + elevation) in ambisonic mode or the MicPanel in stereo mode (same slot, visibility toggled by the 10 Hz mode poll); the SourcePanel (the active-source count plus the selected source's X/Y/Z, always visible in both modes); and an OutputPanel (a row of the direct / early / reverb volumes and the tap count, with the order/output dropdown and the Doppler switch on a row beneath).
  • The top view is map-style: the listener's rest orientation (azimuth 0, the +X room axis) points up (vertical = X, horizontal = Y mirrored). The gaze arrow in each 2D view is the projection of the true look direction — it shortens as the gaze leaves the plane. The 3D view adds a floor grid and per-object floor stems for height readability.
  • Mouse wheel over a reception point rotates it (PlaneView:: mouseWheelMove): over a microphone it turns that mic's azimuth (the MicPanel link, if engaged, propagates the delta to the other mic); over the listener it turns azimuth in the top (horizontal) view and elevation in the side (Z-bearing) view. Azimuth wraps at ±180°, elevation clamps to the parameter range; ~10° per notch.
  • The wall Link toggles gang a knob row at the GUI level (parameter writes fan out on the message thread), so host automation of a single wall still works when unlinked.
  • Walls are clickable in the 2D views: clicking a room edge selects that wall (selectedWall, shared editor state like the source selection) and pops up a small in-view overlay with that wall's damping + HF damping knobs. The selected edge is highlighted in both 2D views, the corresponding face is lightly shaded in the 3D view, and clicking empty space deselects. All six walls are reachable: X/Y walls in the top view, X walls plus floor and ceiling (Z) in the side view.
  • In stereo mode the views draw the two microphones as labelled dots with their directivity lobe — the polar pattern's cut by the view plane in 2D (|g| as radius, so figure-8 shows both lobes), a metre-true horizontal ring in 3D — plus a pointing tick along the mic axis; the listener is hidden (the mics are the reception points). Mics are dragged like sources; pattern, azimuth and level live in the MicPanel shown next to the source panel while stereo mode is active (the editor polls the output mode at 10 Hz to swap layouts). Between the two mic columns, at the bottom-centre, sit two small toggles: MS (mid/side output, above) and L (link, below). The link keeps the pair rigid: position drags and azimuth turns apply as deltas to the other mic (positions clamp at the walls, azimuth wraps at ±180°), so a configured ORTF/AB pair can be moved and aimed as one object.

12. File map

lib/FxmeTools/FxmeTools/dsp/
  Ambisonics.h        conventions, encodeSN3D, headRotation, ShRotation,
                      diffuse-field weights            (§2)
  RoomAcoustics.h     BoxRoom, image sources + stable ids, Sabine,
                      HF one-pole, tail crossfade      (§3, §5.3)

Source/Dsp/
  AmbiDefs.h          limits + smoothing constants
  RoomParams.h        POD snapshots (room frame — no orientation)
  ImageSourceEngine.* image taps → encoded TapSet, prevIndex matching (§3, §6.2)
  EarlyReflections.*  delay lines, tap render, crossfade + Doppler    (§4, §6)
  LateReverb.*        diffuse IRs + WDL convolution                   (§5)
  AmbiRoomEngine.*    orchestration, rotation stage, threading        (§2.4, §8)
  SpscMailbox.h       lock-free handoff                               (§8)

Source/Components/
  PlaneView.*         draggable 2D views; wall clicks; wheel rotation  (§11)
  RoomView3D.*        orbiting 3D view with mic / listener overlays
  RoomPanel.h         tabbed Dimensions / Damping (embeds WallPanel)   (§11)
  WallPanel.h         per-wall damping + HF grid with Link toggles
  ListenerPanel.h     ambisonic listener X/Y/Z + azimuth/elevation
  MicPanel.h          stereo two-mic pattern/azimuth/level + Link + MS (§2.6)
  SourcePanel.h       active-source count + selected source X/Y/Z
  OutputPanel.h       volumes + tap count, then order dropdown + Doppler