Skip to content

Models.kt

src/jvmMain/kotlin/afield/audio/spatial/steamaudio/Models.kt · 235 lines

package afield.audio.spatial.steamaudio

Per-source models for Steam Audio's direct path: DistanceAttenuationModel, AirAbsorptionModel and Directivity.

import afield.audio.spatial.steamaudio.raw.IPLAirAbsorptionModel
import afield.audio.spatial.steamaudio.raw.IPLDirectivity
import afield.audio.spatial.steamaudio.raw.IPLDistanceAttenuationModel
import afield.audio.spatial.steamaudio.raw.Phonon
import java.lang.foreign.Arena
import java.lang.foreign.MemorySegment
import java.lang.foreign.ValueLayout

public class DistanceAttenuationModel private constructor

How a source's level falls off with distance.

Steam Audio's DEFAULT model is a physically-motivated inverse-square falloff. The inverseDistance variant lets you set the distance below which no further attenuation is applied, which is how you keep a source from becoming painfully loud as the listener walks into it.

public class DistanceAttenuationModel private constructor(
    /** Which model this is. */
    public val type: DistanceAttenuationModelType,
    /** Distance below which attenuation stops increasing, for [inverseDistance]. */
    public val minDistance: Float,
) {

internal fun allocate(arena: Arena): MemorySegment

    internal fun allocate(arena: Arena): MemorySegment =
        IPLDistanceAttenuationModel.allocate(arena).also(::writeInto)

internal fun writeInto(segment: MemorySegment)

    internal fun writeInto(segment: MemorySegment) {
        IPLDistanceAttenuationModel.type(segment, type.value)
        IPLDistanceAttenuationModel.minDistance(segment, minDistance)
        IPLDistanceAttenuationModel.callback(segment, MemorySegment.NULL)
        IPLDistanceAttenuationModel.userData(segment, MemorySegment.NULL)
        // `dirty` tells Steam Audio to re-read a callback-driven curve. With no callback there
        // is nothing to invalidate, so it stays false.
        IPLDistanceAttenuationModel.dirty(segment, 0)
    }

public fun calculate

Evaluates this model for a given source and listener position.

Returns a linear gain in 0..1. Feed it to DirectEffectParams.distanceAttenuation.

This allocates a scratch arena, so it is not for the audio thread in a tight loop -- either call it on your game-logic thread, or let a Simulator compute it.

    public fun calculate(
        context: SteamAudioContext,
        source: Vector3,
        listener: Vector3,
    ): Float {
        val contextHandle = context.liveHandle
        Arena.ofConfined().use { arena ->
            return Phonon.iplDistanceAttenuationCalculate(
                contextHandle,
                source.allocate(arena),
                listener.allocate(arena),
                allocate(arena),
            )
        }
    }

override fun toString(): String = "DistanceAttenuationModel($type, minDistance=$minDistance)"

public companion object

    public companion object {

public val DEFAULT: DistanceAttenuationModel

Steam Audio's built-in physics-based falloff.

        public val DEFAULT: DistanceAttenuationModel =
            DistanceAttenuationModel(DistanceAttenuationModelType.DEFAULT, 1f)

public fun inverseDistance(minDistance: Float): DistanceAttenuationModel

Inverse-distance falloff, clamped so that no extra attenuation is applied closer than minDistance metres.

        public fun inverseDistance(minDistance: Float): DistanceAttenuationModel {
            require(minDistance > 0f) { "minDistance must be positive, got $minDistance" }
            return DistanceAttenuationModel(
                DistanceAttenuationModelType.INVERSE_DISTANCE,
                minDistance,
            )
        }
    }
}

public class AirAbsorptionModel private constructor

How much high-frequency energy the air removes over distance.

Air absorption is what makes a distant sound dull as well as quiet, and it is a strong distance cue -- more reliable than level alone, since level is confounded by how loud the source is.

public class AirAbsorptionModel private constructor(
    /** Which model this is. */
    public val type: AirAbsorptionModelType,
    /** Per-band exponential coefficients, for [exponential]. */
    private val coefficients: FloatArray,
) {

internal fun allocate(arena: Arena): MemorySegment

    internal fun allocate(arena: Arena): MemorySegment =
        IPLAirAbsorptionModel.allocate(arena).also(::writeInto)

internal fun writeInto(segment: MemorySegment)

    internal fun writeInto(segment: MemorySegment) {
        IPLAirAbsorptionModel.type(segment, type.value)
        MemorySegment.copy(
            coefficients, 0,
            IPLAirAbsorptionModel.coefficients(segment), ValueLayout.JAVA_FLOAT, 0L,
            SteamAudio.NUM_BANDS,
        )
        IPLAirAbsorptionModel.callback(segment, MemorySegment.NULL)
        IPLAirAbsorptionModel.userData(segment, MemorySegment.NULL)
        IPLAirAbsorptionModel.dirty(segment, 0)
    }

public fun calculate

Evaluates per-band air absorption gains for a source and listener position.

Writes SteamAudio.NUM_BANDS linear gains into destination, which must be that long. Pass the result to DirectEffectParams.setAirAbsorption.

    public fun calculate(
        context: SteamAudioContext,
        source: Vector3,
        listener: Vector3,
        destination: FloatArray,
    ) {
        require(destination.size == SteamAudio.NUM_BANDS) {
            "destination needs ${SteamAudio.NUM_BANDS} entries, got ${destination.size}"
        }
        val contextHandle = context.liveHandle
        Arena.ofConfined().use { arena ->
            val out = arena.allocate(ValueLayout.JAVA_FLOAT, SteamAudio.NUM_BANDS.toLong())
            Phonon.iplAirAbsorptionCalculate(
                contextHandle,
                source.allocate(arena),
                listener.allocate(arena),
                allocate(arena),
                out,
            )
            MemorySegment.copy(out, ValueLayout.JAVA_FLOAT, 0L, destination, 0, SteamAudio.NUM_BANDS)
        }
    }

override fun toString(): String = "AirAbsorptionModel($type)"

public companion object

    public companion object {

public val DEFAULT: AirAbsorptionModel

Steam Audio's built-in air absorption curve.

        public val DEFAULT: AirAbsorptionModel =
            AirAbsorptionModel(AirAbsorptionModelType.DEFAULT, FloatArray(SteamAudio.NUM_BANDS))

public fun exponential(coefficients: FloatArray): AirAbsorptionModel

Exponential absorption with explicit per-band coefficients.

Larger coefficients absorb faster. Expect values around 0.0002 for low frequencies rising to 0.007 or so for high, which is roughly what air does; exaggerate them for a mistier, more distance-cued space.

        public fun exponential(coefficients: FloatArray): AirAbsorptionModel {
            require(coefficients.size == SteamAudio.NUM_BANDS) {
                "expected ${SteamAudio.NUM_BANDS} coefficients, got ${coefficients.size}"
            }
            return AirAbsorptionModel(
                AirAbsorptionModelType.EXPONENTIAL,
                coefficients.copyOf(),
            )
        }
    }
}

public class Directivity

How much a source radiates in a given direction.

Modelled as a weighted blend between an omnidirectional pattern and a dipole. dipoleWeight of 0 is omnidirectional, 1 is a pure dipole; dipolePower sharpens the lobe.

public class Directivity(
    /** Blend between omnidirectional (`0`) and dipole (`1`). */
    public val dipoleWeight: Float = 0f,
    /** How sharply the dipole lobe narrows. `1` is a plain figure-of-eight. */
    public val dipolePower: Float = 1f,
) {

init

    init {
        require(dipoleWeight in 0f..1f) { "dipoleWeight must be in 0..1, got $dipoleWeight" }
        require(dipolePower > 0f) { "dipolePower must be positive, got $dipolePower" }
    }

internal fun allocate(arena: Arena): MemorySegment

    internal fun allocate(arena: Arena): MemorySegment =
        IPLDirectivity.allocate(arena).also(::writeInto)

internal fun writeInto(segment: MemorySegment)

    internal fun writeInto(segment: MemorySegment) {
        IPLDirectivity.dipoleWeight(segment, dipoleWeight)
        IPLDirectivity.dipolePower(segment, dipolePower)
        IPLDirectivity.callback(segment, MemorySegment.NULL)
        IPLDirectivity.userData(segment, MemorySegment.NULL)
    }

public fun calculate

Evaluates the directivity gain for a source with a given orientation and a listener position.

Returns a linear gain in 0..1, for DirectEffectParams.directivity.

    public fun calculate(
        context: SteamAudioContext,
        source: CoordinateSpace3,
        listener: Vector3,
    ): Float {
        val contextHandle = context.liveHandle
        Arena.ofConfined().use { arena ->
            return Phonon.iplDirectivityCalculate(
                contextHandle,
                source.allocate(arena),
                listener.allocate(arena),
                allocate(arena),
            )
        }
    }

override fun toString(): String = "Directivity(weight=$dipoleWeight, power=$dipolePower)"

public companion object

    public companion object {

public val OMNIDIRECTIONAL: Directivity = Directivity(dipoleWeight = 0f)

Radiates equally in all directions.

public val DIPOLE: Directivity = Directivity(dipoleWeight = 1f, dipolePower = 1f)

A figure-of-eight pattern, radiating forwards and backwards but not sideways.

public val CARDIOID_ISH: Directivity = Directivity(dipoleWeight = 0.5f, dipolePower = 2f)

A forward-biased lobe, roughly like a person speaking.