7.11.16. STRUDEL-16 — HRTF: 3D Positional Override for Pan

daslang exposes a per-event HRTF position (azimuth, elevation) on top of the standard mini-notation. When set, the equal-power stereo pan render is replaced by a binaural-stereo HRTF: the dry signal and the orbit-effect sends are run through the same CIPIC-based ma_hrtf engine that audio_boost uses for 3D sources. Pan still shapes source width / L/R balance feeding the spatialiser; HRTF places the source in 3D.

Why bother — equal-power pan only places sources on a horizontal stereo line between the speakers. HRTF adds:

  • depth / externalisation (sources feel “outside the head” on headphones),

  • front-back disambiguation (centre-front is no longer ambiguous with centre-rear),

  • elevation cues.

Cost — under the binaural-stereo render, each HRTF-positioned voice carries two ma_hrtf instances (hrtf_l + hrtf_r, lazy-allocated on first render: one per virtual-speaker channel at azimuth -/+ 30°). Practical for 3-8 simultaneous HRTF voices on modern hardware. Plain pan is far cheaper; reach for HRTF only when you want spatial cues plain pan can’t provide.

7.11.16.1. API

pat |> hrtf_azimuth(deg)        // numeric or pattern-valued, -180..180
pat |> hrtf_elevation(deg)      // numeric or pattern-valued, -90..90
pat |> hrtf(az, el)             // combined numeric setter

Setting any of the three flips the event’s hrtf_active flag. Pan and HRTF are orthogonal under the binaural-stereo render: pan controls the inherent stereo width / image of the source (it shapes the L/R balance of the input feeding the spatialiser), and hrtf positions the resulting source in 3D. Best heard on headphones.

7.11.16.2. Part A: Static positions

hrtf(az, el) takes both axes at once. 0/0 is directly ahead, +90/0 is right, -90/0 is left, 0/+45 is up-front:

let pat <- stack([
    note("c4", "sine") |> hrtf(0.0,    0.0) |> attack(0.01) |> release(0.5) |> gain(0.4),
    note("e4", "sine") |> hrtf(-90.0,  0.0) |> attack(0.01) |> release(0.5) |> gain(0.4),
    note("g4", "sine") |> hrtf(+90.0,  0.0) |> attack(0.01) |> release(0.5) |> gain(0.4),
    note("c5", "sine") |> hrtf(0.0,   45.0) |> attack(0.01) |> release(0.5) |> gain(0.4)
])

Four notes from four directions: ahead, hard-left, hard-right, up-front.

7.11.16.3. Part B: Animated azimuth

The setters accept patterns. Each event samples the modulation pattern at its onset to get its azimuth. With sine() |> range(-180, 180) |> slow(4), the source orbits the listener once every 8 cycles (on a 0.5 cps stream):

var pat <- (
    note("c4 e4 g4 c5", "triangle")
    |> hrtf_azimuth(sine() |> range(-180.0, 180.0) |> slow(4.0lf))
    |> attack(0.005) |> release(0.3) |> gain(0.4)
)

hrtf_elevation works the same way; combine the two for full spherical motion.

7.11.16.4. Part C: Pan and HRTF combine — HRTF positions, pan widens

The render branch is selected per voice. When hrtf_active is true the equal-power L/R mixer is replaced by a binaural-stereo HRTF: the L and R input channels become virtual sources at azimuth - 30° and azimuth + 30°, each through its own HRTF instance, summed at the output. The pre-pan stereo image (whatever pan produced) is preserved as source width; HRTF places the source in 3D. The same binaural render is reused for the reverb / delay / chorus sends.

var pat <- stack([
    note("c4", "sine") |> pan(0.5)                     |> gain(0.4),
    note("e4", "sine") |> pan(0.5) |> hrtf(-90.0, 0.0) |> gain(0.4)
])

The second voice has both pan(0.5) and hrtf(-90, 0). The voice is positioned to the left by HRTF; the slight L/R imbalance that pan(0.5) produced is preserved as source width inside that 3D position (rather than being thrown away).

7.11.16.5. Part D: Mixing HRTF and plain pan

HRTF is heavier than pan and works best on headphones. For drum machines or beats where you just want left/right separation, plain pan is cheaper and works fine on speakers. Mix-and-match in the same stack: HRTF the lead voice, pan the rhythm section:

var pat <- stack([
    s("bd ~ sd ~")                  |> orbit(0) |> gain(0.6),
    s("hh*8")                       |> orbit(0) |> pan(0.7) |> gain(0.3),
    note("c4 e4 g4 e4", "triangle") |> orbit(1)
        |> hrtf_azimuth(sine() |> range(-60.0, 60.0) |> slow(2.0lf))
        |> room(0.4) |> roomsize(2.0) |> gain(0.35)
])

Because the lead is on a separate orbit, its reverb stays distinct from the (dry) drum bus. The reverb send for the lead voice is itself HRTF-positioned, so the room follows the source through space.

See also

Full source: tutorials/daStrudel/daStrudel_16_hrtf_position.das

Standalone examples: examples/daStrudel/features/hrtf_basic.das, examples/daStrudel/features/hrtf_animated.das, examples/daStrudel/features/hrtf_overrides_pan.das

Previous tutorial: STRUDEL-15 — Live-Reloading Patterns

Mini-notation compatibility: mini-notation compatibility

Full daStrudel reference: Strudel (Live Coding)