Skip to content

PathEffect.kt

src/jvmMain/kotlin/afield/audio/spatial/steamaudio/PathEffect.kt · 403 lines

package afield.audio.spatial.steamaudio

import afield.audio.spatial.steamaudio.raw.IPLPathEffectParams
import afield.audio.spatial.steamaudio.raw.IPLPathEffectSettings
import afield.audio.spatial.steamaudio.raw.Phonon
import java.lang.foreign.Arena
import java.lang.foreign.MemorySegment
import java.lang.foreign.ValueLayout

Rendering sound that reaches the listener around obstacles along baked probe paths.

PathEffect spatializes a source using the paths a Simulator found, configured per block by PathEffectParams.

public class PathEffectParams private constructor

Per-block parameters for a PathEffect.

A Simulator fills in the two fields that describe the paths it found -- eqCoefficients and the spherical-harmonic coefficients -- while the application owns order, binaural, hrtf, listener and normalizeEq, which describe how you want those paths rendered. copySimulatedFrom moves the former across while preserving the latter.

The shCoeffs pointer

The simulated directional distribution arrives as a raw IPLfloat32* pointing into memory the simulator owns, not a copy. Two consequences:

  • The coefficients are only valid until the next Simulator.runPathing overwrites them. In the usual arrangement -- simulation on its own thread, audio reading per block -- that is a benign race for a slowly-changing quantity, and it is what Steam Audio's own integrations do. If you need a hard guarantee, double-buffer by snapshotting with copyShCoefficientsFrom.
  • They are in world space and must be rotated into the listener's frame. When PathEffect.spatializes is true the effect does that for you from listener; when it is false you must rotate the Ambisonic output yourself with an AmbisonicsRotationEffect.
public class PathEffectParams private constructor(
    internal val segment: MemorySegment,
    /** Optional caller-owned storage used by [copyShCoefficientsFrom]. */
    private val shSnapshot: MemorySegment,
) {

public constructor

Allocates a parameter block from arena.

@param order Ambisonic order to render at. May be below the effect's maximum, which reduces cost. @param snapshotShCoefficients reserve room for order's worth of coefficients so copyShCoefficientsFrom can take a private copy of the simulator's output. Only needed if you want to decouple the audio thread from the simulation thread entirely.

    public constructor(
        arena: Arena,
        order: Int = 1,
        snapshotShCoefficients: Boolean = false,
    ) : this(
        segment = IPLPathEffectParams.allocate(arena),
        shSnapshot = if (snapshotShCoefficients) {
            arena.allocate(
                ValueLayout.JAVA_FLOAT,
                SteamAudio.ambisonicChannelCount(order).toLong(),
            )
        } else {
            MemorySegment.NULL
        },
    ) {
        this.order = order
        this.binaural = true
        this.normalizeEq = true
        this.listener = CoordinateSpace3.IDENTITY
        IPLPathEffectParams.shCoeffs(segment, MemorySegment.NULL)
        IPLPathEffectParams.hrtf(segment, MemorySegment.NULL)
        for (band in 0 until SteamAudio.NUM_BANDS) setEqCoefficient(band, 1f)
    }

public fun setEqCoefficient(band: Int, gain: Float)

Per-band EQ modelling the frequency-dependent loss of sound bending around an obstacle.

Low frequencies diffract around a corner far better than high ones, so a path that goes round a wall arrives dull. This is the coefficient set that produces that, and it is what makes pathing sound like sound going around something rather than through it.

    public fun setEqCoefficient(band: Int, gain: Float) {
        checkBand(band)
        IPLPathEffectParams.eqCoeffs(segment)
            .setAtIndex(ValueLayout.JAVA_FLOAT, band.toLong(), gain)
    }

public fun eqCoefficient(band: Int): Float

Reads back a per-band EQ coefficient.

    public fun eqCoefficient(band: Int): Float {
        checkBand(band)
        return IPLPathEffectParams.eqCoeffs(segment)
            .getAtIndex(ValueLayout.JAVA_FLOAT, band.toLong())
    }

public var order: Int

Ambisonic order to render at.

    public var order: Int
        get() = IPLPathEffectParams.order(segment)
        set(value) = IPLPathEffectParams.order(segment, value)

public var binaural: Boolean

Render binaurally via HRTF. Only used when the effect was created with spatialisation.

    public var binaural: Boolean
        get() = IPLPathEffectParams.binaural(segment) != 0
        set(value) = IPLPathEffectParams.binaural(segment, if (value) 1 else 0)

public var normalizeEq: Boolean

Divide the EQ coefficients by their largest value before use.

Worth leaving on. A physics-based deviation model can attenuate hard enough that a pathed source becomes inaudible; normalising preserves the relative spectral tilt -- which is the audible cue for going around a corner -- without the absolute loss.

    public var normalizeEq: Boolean
        get() = IPLPathEffectParams.normalizeEQ(segment) != 0
        set(value) = IPLPathEffectParams.normalizeEQ(segment, if (value) 1 else 0)

public var listener: CoordinateSpace3

The listener's frame, used to rotate the world-space field when spatialising.

    public var listener: CoordinateSpace3
        get() = CoordinateSpace3.readFrom(IPLPathEffectParams.listener(segment))
        set(value) = value.writeTo(IPLPathEffectParams.listener(segment))

public var hrtf: Hrtf?

The HRTF used for binaural rendering. Held as a raw pointer, so it must stay alive.

    public var hrtf: Hrtf? = null
        set(value) {
            IPLPathEffectParams.hrtf(segment, value.handleOrNull())
            field = value
        }

public fun copySimulatedFrom(outputs: SimulationOutputs)

Takes the simulated path parameters from outputs, preserving the fields the application owns rather than the simulator.

A blind struct copy would overwrite order, binaural, hrtf, listener and normalizeEq with whatever the simulator left there.

    public fun copySimulatedFrom(outputs: SimulationOutputs) {
        val savedOrder = order
        val savedBinaural = binaural
        val savedNormalize = normalizeEq
        val savedHrtf = hrtf
        val savedListener = listener

        segment.copyFrom(outputs.pathingSegment)

        order = savedOrder
        binaural = savedBinaural
        normalizeEq = savedNormalize
        savedHrtf?.let { hrtf = it }
        listener = savedListener
    }

public fun copyShCoefficientsFrom(outputs: SimulationOutputs)

Copies the simulator's spherical-harmonic coefficients into this object's own storage and points the struct at the copy, so the audio thread no longer reads simulator memory.

Requires snapshotShCoefficients = true at construction. Call it after copySimulatedFrom.

    public fun copyShCoefficientsFrom(outputs: SimulationOutputs) {
        check(shSnapshot.address() != 0L) {
            "this PathEffectParams was created without snapshotShCoefficients = true"
        }
        val source = IPLPathEffectParams.shCoeffs(outputs.pathingSegment)
        if (source.address() == 0L) {
            IPLPathEffectParams.shCoeffs(segment, MemorySegment.NULL)
            return
        }
        val count = SteamAudio.ambisonicChannelCount(order).toLong()
        val available = shSnapshot.byteSize() / Float.SIZE_BYTES
        val copies = minOf(count, available)
        MemorySegment.copy(
            source.reinterpret(copies * Float.SIZE_BYTES), 0L,
            shSnapshot, 0L,
            copies * Float.SIZE_BYTES,
        )
        IPLPathEffectParams.shCoeffs(segment, shSnapshot)
    }

public fun shCoefficient(index: Int): Float

The simulated spherical-harmonic coefficients, read back for inspection.

Mostly useful for debugging: a source with no valid path has all-zero coefficients, which is the quickest way to tell "pathing found nothing" from "pathing found a quiet path".

    public fun shCoefficient(index: Int): Float {
        val pointer = IPLPathEffectParams.shCoeffs(segment)
        if (pointer.address() == 0L) return 0f
        return pointer.reinterpret((index + 1).toLong() * Float.SIZE_BYTES)
            .getAtIndex(ValueLayout.JAVA_FLOAT, index.toLong())
    }

public fun hasNoPath(): Boolean

True when every coefficient is zero, meaning no path was found.

    public fun hasNoPath(): Boolean {
        val pointer = IPLPathEffectParams.shCoeffs(segment)
        if (pointer.address() == 0L) return true
        val count = SteamAudio.ambisonicChannelCount(order)
        val view = pointer.reinterpret(count.toLong() * Float.SIZE_BYTES)
        for (index in 0 until count) {
            if (view.getAtIndex(ValueLayout.JAVA_FLOAT, index.toLong()) != 0f) return false
        }
        return true
    }

    private fun checkBand(band: Int) {
        if (band !in 0 until SteamAudio.NUM_BANDS) {
            throw IndexOutOfBoundsException("band $band outside 0..<${SteamAudio.NUM_BANDS}")
        }
    }
}

public class PathEffect private constructor

Renders sound that reaches the listener by travelling around geometry rather than through it.

This is the effect that makes a corner behave like a corner. Occlusion alone says "the direct line is blocked, so attenuate"; pathing says "the direct line is blocked, but there is a way round through that doorway, so the sound arrives from the doorway, dulled by having bent to get there". In an enclosed 2D world of corridors and rooms that difference is most of the spatial information the player has.

It needs probes

Unlike every other effect here, pathing is not purely geometric at runtime. It searches a precomputed visibility graph between ProbeBatch probes, so a path can only be found where probes exist and where PathBaker has recorded which probes can see one another. Without baked pathing data in a probe batch attached to the simulator, Simulator.runPathing finds nothing and this effect renders silence.

Because the graph is baked, pathing assumes the geometry it was baked against. Moving walls do not re-route paths -- see the note on PathBaker.

Input and output

Input is the source's mono dry signal.

Output depends on how the effect was created. With spatializes true the effect renders directly to a spatialised buffer -- stereo, for a binaural or stereo speaker layout -- which upstream notes is significantly faster than decoding separately. With spatializes false it renders raw Ambisonics with (maxOrder + 1)^2 channels, which you then rotate and decode yourself; useful when several Ambisonic sources are being mixed before spatialisation.

expectedOutputChannels reports which of the two applies.

public class PathEffect private constructor(
    handle: MemorySegment,
    /** Highest Ambisonic order this effect can render. */
    public val maxOrder: Int,
    /** Whether this effect spatialises internally rather than emitting raw Ambisonics. */
    public val spatializes: Boolean,
    /** The speaker layout used when spatialising. */
    public val speakerLayout: SpeakerLayout,
    /**
     * Holds a custom speaker layout's direction array, which Steam Audio keeps a raw pointer
     * to. Null for standard layouts, which store no pointer.
     */
    private val layoutArena: Arena?,
) : AudioEffect(handle) {

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

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

override fun onClosed()

    override fun onClosed() {
        layoutArena?.close()
    }

public val ambisonicChannelCount: Int get() = SteamAudio.ambisonicChannelCount(maxOrder)

Number of Ambisonic channels at maxOrder.

public val expectedOutputChannels: Int

The number of output channels apply expects.

The speaker layout's channel count when spatializes is true, otherwise the Ambisonic channel count.

    public val expectedOutputChannels: Int
        get() = if (spatializes) speakerLayout.channelCount else ambisonicChannelCount

public fun apply

Renders one block.

@param input the source's mono dry signal. @param output a buffer with expectedOutputChannels channels. Cannot alias input -- this effect does not work in place.

    public fun apply(
        params: PathEffectParams,
        input: AudioBuffer,
        output: AudioBuffer,
    ): AudioEffectState {
        require(input.channelCount == 1) {
            "a path effect takes mono input, got ${input.channelCount} channels"
        }
        require(output.channelCount >= expectedOutputChannels) {
            "output needs at least $expectedOutputChannels channels " +
                "(spatialize=$spatializes, maxOrder=$maxOrder), got ${output.channelCount}"
        }
        require(input.sampleCount == output.sampleCount) {
            "input and output must be the same length: ${input.sampleCount} vs ${output.sampleCount}"
        }
        return AudioEffectState.from(
            Phonon.iplPathEffectApply(liveHandle, params.segment, input.struct, output.struct),
        )
    }

override fun reset()

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

override val tailSize: Int

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

override fun getTail(output: AudioBuffer): AudioEffectState

    override fun getTail(output: AudioBuffer): AudioEffectState {
        require(output.channelCount >= expectedOutputChannels) {
            "output needs at least $expectedOutputChannels channels, got ${output.channelCount}"
        }
        return AudioEffectState.from(Phonon.iplPathEffectGetTail(liveHandle, output.struct))
    }

public fun retain(): PathEffect

Increments the reference count and returns an independently closeable wrapper.

    public fun retain(): PathEffect = PathEffect(
        Phonon.iplPathEffectRetain(liveHandle),
        maxOrder,
        spatializes,
        speakerLayout,
        // The retained wrapper shares the original's layout storage rather than owning a copy.
        layoutArena = null,
    )

public companion object

    public companion object {

public operator fun invoke

Creates a path effect that spatialises internally, rendering straight to speakerLayout.

This is the cheaper arrangement and the right default when the pathed signal goes directly to the output bus.

@param hrtf used when PathEffectParams.binaural is set. Not retained, so it must outlive the effect.

        public operator fun invoke(
            context: SteamAudioContext,
            audioSettings: AudioSettings,
            hrtf: Hrtf,
            maxOrder: Int = 1,
            speakerLayout: SpeakerLayout = SpeakerLayout.STEREO,
        ): PathEffect = create(context, audioSettings, hrtf, maxOrder, true, speakerLayout)

public fun unspatialized

Creates a path effect that emits raw Ambisonics, leaving rotation and decoding to you.

Use this when several Ambisonic buses are summed before a single decode -- the same saving a ReflectionMixer gives for reflections. Remember that the coefficients are world-space: rotate with an AmbisonicsRotationEffect before decoding, or the field will not follow the listener's head.

body — 43 lines, from line 360
        public fun unspatialized(
            context: SteamAudioContext,
            audioSettings: AudioSettings,
            maxOrder: Int = 1,
        ): PathEffect = create(context, audioSettings, null, maxOrder, false, SpeakerLayout.STEREO)

        private fun create(
            context: SteamAudioContext,
            audioSettings: AudioSettings,
            hrtf: Hrtf?,
            maxOrder: Int,
            spatialize: Boolean,
            speakerLayout: SpeakerLayout,
        ): PathEffect {
            require(maxOrder >= 0) { "maxOrder cannot be negative, got $maxOrder" }
            val contextHandle = context.liveHandle
            // A custom layout's direction array is referenced by pointer for the effect's
            // lifetime, so it cannot live in the scratch arena.
            var layoutArena: Arena? = null
            try {
                if (speakerLayout.needsPersistentStorage) layoutArena = Arena.ofShared()
                val handle = Arena.ofConfined().use { scratch ->
                    val audio = audioSettings.allocate(scratch)
                    val settings = IPLPathEffectSettings.allocate(scratch)
                    IPLPathEffectSettings.maxOrder(settings, maxOrder)
                    IPLPathEffectSettings.spatialize(settings, if (spatialize) 1 else 0)
                    IPLPathEffectSettings.hrtf(settings, hrtf.handleOrNull())
                    speakerLayout.writeInto(
                        IPLPathEffectSettings.speakerLayout(settings),
                        layoutArena ?: scratch,
                    )
                    createHandle("iplPathEffectCreate") { out ->
                        Phonon.iplPathEffectCreate(contextHandle, audio, settings, out)
                    }
                }
                return PathEffect(handle, maxOrder, spatialize, speakerLayout, layoutArena)
            } catch (failure: Throwable) {
                layoutArena?.close()
                throw failure
            }
        }
    }
}