Skip to content

BinauralEffect.kt

src/jvmMain/kotlin/afield/audio/spatial/steamaudio/BinauralEffect.kt · 228 lines

package afield.audio.spatial.steamaudio

HRTF-based binaural rendering of a mono source: BinauralEffect and the native-memory BinauralEffectParams it reads each block.

import afield.audio.spatial.steamaudio.raw.IPLBinauralEffectParams
import afield.audio.spatial.steamaudio.raw.IPLBinauralEffectSettings
import afield.audio.spatial.steamaudio.raw.IPLVector3
import afield.audio.spatial.steamaudio.raw.Phonon
import java.lang.foreign.Arena
import java.lang.foreign.MemorySegment
import java.lang.foreign.ValueLayout

public class BinauralEffectParams private constructor

Per-block parameters for a BinauralEffect, held directly in native memory.

This is a mutable view over an IPLBinauralEffectParams struct rather than a data class, so that updating it each block costs nothing on the JVM heap. Allocate one per source from a long-lived Arena and mutate it in place:

val params = BinauralEffectParams(arena, hrtf)
// per block, on the audio thread:
params.setDirection(x, y, z)
binaural.apply(params, input, output)

The struct keeps a raw pointer to its hrtf, so that HRTF must stay alive and unclosed for as long as this object is in use.

public class BinauralEffectParams private constructor(
    internal val segment: MemorySegment,
    private val peakDelaySlot: MemorySegment,
) {

public constructor

Allocates a parameter block from arena.

@param hrtf the HRTF to render with. Required -- Steam Audio reads it on every apply. @param trackPeakDelays when true, reserves room for the per-ear peak delays that Steam Audio can report back, readable afterwards via leftPeakDelay and rightPeakDelay. Leave this off unless you need them; it costs the native side a little extra work.

    public constructor(
        arena: Arena,
        hrtf: Hrtf,
        interpolation: HrtfInterpolation = HrtfInterpolation.BILINEAR,
        spatialBlend: Float = 1.0f,
        trackPeakDelays: Boolean = false,
    ) : this(
        segment = IPLBinauralEffectParams.allocate(arena),
        peakDelaySlot = if (trackPeakDelays) {
            arena.allocate(ValueLayout.JAVA_FLOAT, 2L)
        } else {
            MemorySegment.NULL
        },
    ) {
        this.hrtf = hrtf
        this.interpolation = interpolation
        this.spatialBlend = spatialBlend
        setDirection(0f, 0f, -1f)
        IPLBinauralEffectParams.peakDelays(segment, peakDelaySlot)
    }

public fun setDirection(x: Float, y: Float, z: Float)

Direction from the listener to the source, in the listener's local frame: +x right, +y up, -z ahead.

Need not be normalised; Steam Audio only uses the direction. Use SteamAudioContext.calculateRelativeDirection to convert a world-space source and listener into this frame.

This overload avoids constructing a Vector3, so it is the one to call per block.

    public fun setDirection(x: Float, y: Float, z: Float) {
        val direction = IPLBinauralEffectParams.direction(segment)
        IPLVector3.x(direction, x)
        IPLVector3.y(direction, y)
        IPLVector3.z(direction, z)
    }

public var direction: Vector3

The source direction, in the listener's local frame. Allocates on read; see setDirection.

    public var direction: Vector3
        get() = Vector3.readFrom(IPLBinauralEffectParams.direction(segment))
        set(value) = setDirection(value.x, value.y, value.z)

public var interpolation: HrtfInterpolation

How HRTF data is interpolated between measured directions.

    public var interpolation: HrtfInterpolation
        get() = HrtfInterpolation.from(IPLBinauralEffectParams.interpolation(segment))
        set(value) = IPLBinauralEffectParams.interpolation(segment, value.value)

public var spatialBlend: Float

Blend between unspatialised (0) and fully spatialised (1) output.

Values below 1 pull the source towards the centre of the head, which is useful for sources that should feel close and diffuse rather than precisely placed.

    public var spatialBlend: Float
        get() = IPLBinauralEffectParams.spatialBlend(segment)
        set(value) {
            require(value in 0f..1f) { "spatialBlend must be in 0..1, got $value" }
            IPLBinauralEffectParams.spatialBlend(segment, value)
        }

public var hrtf: Hrtf?

The HRTF used to render.

Stored as a raw pointer, so the Hrtf must outlive this parameter block. Reading this property returns the wrapper last assigned, not a value read back from native memory.

    public var hrtf: Hrtf? = null
        set(value) {
            requireNotNull(value) { "a BinauralEffect needs an HRTF" }
            IPLBinauralEffectParams.hrtf(segment, value.liveHandle)
            field = value
        }

public val leftPeakDelay: Float?

Peak delay for the left ear in samples, or null unless trackPeakDelays was set.

    public val leftPeakDelay: Float?
        get() = if (peakDelaySlot.address() == 0L) null else peakDelaySlot.getAtIndex(ValueLayout.JAVA_FLOAT, 0L)

public val rightPeakDelay: Float?

Peak delay for the right ear in samples, or null unless trackPeakDelays was set.

    public val rightPeakDelay: Float?
        get() = if (peakDelaySlot.address() == 0L) null else peakDelaySlot.getAtIndex(ValueLayout.JAVA_FLOAT, 1L)
}

public class BinauralEffect private constructor(handle: MemorySegment) : AudioEffect(handle)

Renders a mono source to stereo using a head-related transfer function, so it appears to come from a specific direction relative to the listener.

This is the core of spatial audio: given a mono signal and a direction, it produces the interaural time and level differences, plus the spectral shaping from the head and outer ear, that the ear uses to localise a sound.

Input must be mono; output must be stereo.

val effect = BinauralEffect(context, settings, hrtf)
val params = BinauralEffectParams(arena, hrtf)
params.setDirection(dx, dy, dz)
effect.apply(params, mono, stereo)

A binaural effect is stateful -- it holds the filter history that makes movement sound smooth -- so use one effect per source and keep it for that source's lifetime. Sharing one effect between sources, or recycling it without reset, causes audible clicks.

public class BinauralEffect private constructor(handle: MemorySegment) : AudioEffect(handle) {

override fun releaseAction(): (MemorySegment) -> Unit = Phonon::iplBinauralEffectRelease

override val typeName: String get() = "BinauralEffect"

public fun apply

Renders one block.

@param input a mono buffer of the block size this effect was created with. @param output a stereo buffer of the same length. @return whether the effect still holds a tail. For a binaural effect the tail is short (the length of the HRTF impulse response), but it is not zero.

    public fun apply(
        params: BinauralEffectParams,
        input: AudioBuffer,
        output: AudioBuffer,
    ): AudioEffectState {
        require(input.channelCount == 1) {
            "a binaural effect takes mono input, got ${input.channelCount} channels"
        }
        require(output.channelCount == 2) {
            "a binaural effect produces stereo output, got ${output.channelCount} channels"
        }
        require(input.sampleCount == output.sampleCount) {
            "input and output must be the same length: " +
                "${input.sampleCount} vs ${output.sampleCount}"
        }
        return AudioEffectState.from(
            Phonon.iplBinauralEffectApply(liveHandle, params.segment, input.struct, output.struct),
        )
    }

override fun reset()

    override fun reset() {
        Phonon.iplBinauralEffectReset(liveHandle)
    }

override val tailSize: Int

    override val tailSize: Int
        get() = Phonon.iplBinauralEffectGetTailSize(liveHandle)

override fun getTail(output: AudioBuffer): AudioEffectState

    override fun getTail(output: AudioBuffer): AudioEffectState {
        require(output.channelCount == 2) {
            "a binaural effect produces stereo output, got ${output.channelCount} channels"
        }
        return AudioEffectState.from(Phonon.iplBinauralEffectGetTail(liveHandle, output.struct))
    }

public fun retain(): BinauralEffect = BinauralEffect(Phonon.iplBinauralEffectRetain(liveHandle))

Increments the reference count and returns an independently closeable wrapper.

public companion object

    public companion object {

public operator fun invoke

Creates a binaural effect.

@param hrtf the HRTF this effect renders with. It is not retained, so it must outlive the effect.

        public operator fun invoke(
            context: SteamAudioContext,
            audioSettings: AudioSettings,
            hrtf: Hrtf,
        ): BinauralEffect {
            val contextHandle = context.liveHandle
            Arena.ofConfined().use { arena ->
                val audio = audioSettings.allocate(arena)
                val settings = IPLBinauralEffectSettings.allocate(arena)
                IPLBinauralEffectSettings.hrtf(settings, hrtf.liveHandle)
                return BinauralEffect(
                    createHandle("iplBinauralEffectCreate") { out ->
                        Phonon.iplBinauralEffectCreate(contextHandle, audio, settings, out)
                    },
                )
            }
        }
    }
}