Place a sound in a modeled listening space and explore how position affects its output.
padT = play pad chords |> place 320 10 -- 40° left (constants wrap), 10° up
orbit = macro "orbit" 0 (lfoSaw @ 16c |> scale [0, 1])
arpT = play pluck line |> place orbit 0 1.5 -- circles the head every 16 cycles
farT = play bell riff |> place 180 35 2 -- behind, above, twice the distance
1. Surface#
track |> place azimuth elevation [distance] — a track effect (one instance on the mixer bus, at its position in the chain, like panTrack/width).
| Slot | Units | Range | Notes |
|---|---|---|---|
azimuth | degrees | −180 … 180 | 0 = front, +90 = hard right, ±180 = behind. Constants wrap mod 360 — write 320 for −40° (the negative-literal-in-application workaround). |
elevation | degrees | −90 … 90 | clamped. 0 = ear level, +90 = overhead. |
distance | unitless | 0 … 4 | clamped; optional, default 1 = reference. Gain 1/d beyond 1 (no boost inside); the native engine also low-passes with distance (air absorption). |
Point source. place collapses the bus to mono before spatializing — that's what a position means. width/haas earlier in the chain are inert (allowed, not rejected). Reverb after a place stays a normal stereo tail around the placed source — usually what you want.
Live modulation. Each slot accepts a bare macro; the knob's [0, 1] travel maps across the slot's full range (az [−180, 180], el [−90, 90], dist [0, 4] — the width convention; macro 0.5 = az 0° / el 0° / dist 2). LFO- or pattern-driven placement is the macro-modulation idiom: macro "orbit" 0 (lfoSaw @ 16c |> scale [0, 1]) sweeps a full circle every 16 cycles. Azimuth smoothing takes the short way around the circle, so a saw-driven orbit crosses ±180° cleanly instead of swooshing back across the front.
2. IR#
{ "op": "Place", "azimuth": -40.0, // Param: const (degrees)
"elevation": { "macro_ref": "lift", "initial": 0.7 }, // Param: live macro
"distance": 1.0 }
Param::Const carries surface units; Param::Macro carries the [0, 1] knob initial — the engine applies the range map (including at build, so the first block starts where the knob sits). Lowering: compiler/src/lower/track.rs (BuiltinKind::Place) — azimuth wraps, elevation/distance clamp, distance defaults to Const(1.0).
3. Browser#
One Rust engine renders everywhere, so the browser gets exactly §4's structural binaural, via engine-wasm/ in an AudioWorklet. No platform HRTF panner, no distance-filter divergence: the Web Audio graph was deleted 2026-07-24.
4. Native engine (reference) — engine/src/binaural.rs#
A structural (parametric) binaural model — asset-free, deterministic, smooth under continuous modulation. Per sample, after the mono collapse:
- Distance — inverse gain
1/(1 + max(d−1, 0))+ an air-absorption one-pole (≈20 kHz at d ≤ 1 → ≈6 kHz at d = 4). - ITD — Woodworth spherical head (a = 8.75 cm, c = 343 m/s), per-ear fractional delays, per-sample-ramped across each block and read with 4-point Hermite (
dsp::hermite4) on a common +1-sample bias. The read was 2-point linear until the 2026-08-03 DSP audit §A7 measured the interpolator's own frac-dependent droop riding the (perfectly smooth) Woodworth delay: an orbit swished the shadowed ear 3.7 dB at 16 kHz, 6.0 dB across az 84°→90°. Now 1.9 / 3.2 dB, and 0.9 → 0.14 dB at 8 kHz. Those figures are at 48 kHz. The residual is the Hermite kernel's own droop, which is a function off/fs, so it does not transfer: the same az 84°→90° spread at 16 kHz measures 4.58 dB at 44.1 kHz, 3.24 dB at 48, and 0.23 dB at 96 — a 96 kHz session gets what 48 kHz gives at 8 kHz. - ILD — Brown–Duda head-shadow one-pole-one-zero per ear (
H(s) = (αs + β)/(s + β),β = 2c/a,α = 1 + cosΓ): unity at DC, the far ear loses highs, the near ear gains up to +6 dB. - Elevation — a pinna notch (−10 dB, Q 4) sweeping ~3.3 kHz → ~13 kHz as elevation rises −90° → +90°.
Targets smooth per block (~20 ms). Unit tests pin the ILD sign at az ±90, the Woodworth ITD magnitude, exact L/R mirror symmetry, notch movement with elevation, the inverse distance law, and bit determinism (engine/src/binaural.rs::tests); the offline smoke test covers the build/process wiring (offline.rs::new_track_effects_render_audible). engine/tests/stereo_interp.rs pins the two things an orbit can get wrong — binaural_orbit_has_no_interpolator_swish (the ITD read's frac-dependent droop) and place_delay_still_ramps_per_sample (block-grid sideband energy, which unlike seam-vs-inside |Δ²x| actually separates a ramped delay from a stepped one).
Why structural, not measured HRIRs: no embedded binary asset (size/licensing), no inter-direction interpolation artifacts, zipper-free under the orbit use case, ~200 lines. Measured-HRIR convolution is the fidelity ceiling → design spec Stage 3.
5. Touch points#
The standard track-effect anatomy (Contribute a language feature): BuiltinKind::Place + registry (compiler/src/builtins.rs), TrackEffect::Place (ir/src/ir.rs), lowering (compiler/src/lower/track.rs) + TrackRole::Effect in BuiltinKind::track_role, which drives both guards (compiler/src/lower.rs track-name detection, compiler/src/lower/expr.rs not-callable — they were two hand-maintained lists until 2026-08-15), the runtime/src/types.ts mirror, engine EffectState::Place (engine/src/effect.rs — build / process / reset / apply_macros), the catalogue entry in compiler/assets/docs-data.json, and examples/songs/orbit.volta (+ golden snapshot).
6. Gotchas#
- Negative constants: application args can't take a bare negative literal; write
place 320 10(wrap) orplace (0 - 40) 10. - Not a master insert: spatializing the summed master is a category error (
placecollapses its input to ONE point source). Above a single track its home is a sub-mix —submix flyers = [lead, pad, tone]withinserts = [place az 0 dist]places the sum of a few tracks, and the live keys of a patch, as one source (Mixing tracks and buses §1.3). - A
soundtrack pipes throughplacelike any track. pulseT |> place 0 0is not a no-op: the bus still collapses to a point source and gets the front-center HRTF coloration (that's the anchor cue).