ReflectionEffect.kt
src/jvmMain/kotlin/afield/audio/spatial/steamaudio/ReflectionEffect.kt · 412 lines
package afield.audio.spatial.steamaudio
import afield.audio.spatial.steamaudio.raw.IPLReflectionEffectParams
import afield.audio.spatial.steamaudio.raw.IPLReflectionEffectSettings
import afield.audio.spatial.steamaudio.raw.Phonon
import java.lang.foreign.Arena
import java.lang.foreign.MemorySegment
import java.lang.foreign.ValueLayout
Rendering simulated reflections onto a source's audio.
ReflectionEffect applies an impulse response or parametric reverb per block, driven by a ReflectionEffectParams; a ReflectionMixer sums several sources' reflections so the expensive final stage runs once.
public class ReflectionEffectParams private constructor
Per-block parameters for a ReflectionEffect.
A Simulator fills most of this in for you -- see SimulationOutputs.reflections -- but
three fields are the application's responsibility and are not written by the simulator:
channelCount, irSize and type. Those describe the shape of the effect you created, and
if they disagree with it the effect will misread the impulse response. applyShapeOf sets
them from an effect, which is the safe way to do it.
val params = ReflectionEffectParams(arena)
// once:
params.applyShapeOf(reflectionEffect)
// per simulation update:
source.getOutputs(SimulationFlags.REFLECTIONS, outputs)
params.copySimulatedFrom(outputs) // preserves the three shape fields
public class ReflectionEffectParams private constructor(
internal val segment: MemorySegment,
) {
public constructor(arena: Arena) : this(IPLReflectionEffectParams.allocate(arena))
Allocates a parameter block from arena.
public constructor(arena: Arena) : this(IPLReflectionEffectParams.allocate(arena)) {
type = ReflectionEffectType.CONVOLUTION
IPLReflectionEffectParams.ir(segment, MemorySegment.NULL)
IPLReflectionEffectParams.tanDevice(segment, MemorySegment.NULL)
IPLReflectionEffectParams.tanSlot(segment, 0)
delay = 0
for (band in 0 until SteamAudio.NUM_BANDS) {
setReverbTime(band, 1f)
setEqualiser(band, 1f)
}
}
public var type: ReflectionEffectType
Which reflection algorithm these parameters describe. Must match the effect.
public var type: ReflectionEffectType
get() = ReflectionEffectType.from(IPLReflectionEffectParams.type(segment))
set(value) = IPLReflectionEffectParams.type(segment, value.value)
public var channelCount: Int
Number of channels in the impulse response. Must match the effect.
public var channelCount: Int
get() = IPLReflectionEffectParams.numChannels(segment)
set(value) = IPLReflectionEffectParams.numChannels(segment, value)
public var irSize: Int
Impulse response length in samples. Must match the effect.
public var irSize: Int
get() = IPLReflectionEffectParams.irSize(segment)
set(value) = IPLReflectionEffectParams.irSize(segment, value)
public var delay: Int
Samples of delay before the reflections begin.
public var delay: Int
get() = IPLReflectionEffectParams.delay(segment)
set(value) = IPLReflectionEffectParams.delay(segment, value)
public fun setReverbTime(band: Int, seconds: Float)
RT60 reverb decay time per band, in seconds -- how long it takes the reverb to fall by 60 dB.
Used by ReflectionEffectType.PARAMETRIC and the late half of
ReflectionEffectType.HYBRID. This is the number that makes a space feel like a cave:
a few seconds at low frequencies, shortening at high frequencies as air and surfaces
absorb.
public fun setReverbTime(band: Int, seconds: Float) {
checkBand(band)
IPLReflectionEffectParams.reverbTimes(segment)
.setAtIndex(ValueLayout.JAVA_FLOAT, band.toLong(), seconds)
}
public fun reverbTime(band: Int): Float
Reads back a per-band RT60.
public fun reverbTime(band: Int): Float {
checkBand(band)
return IPLReflectionEffectParams.reverbTimes(segment)
.getAtIndex(ValueLayout.JAVA_FLOAT, band.toLong())
}
public fun setEqualiser(band: Int, gain: Float)
Per-band gain applied to the reflected signal.
public fun setEqualiser(band: Int, gain: Float) {
checkBand(band)
IPLReflectionEffectParams.eq(segment)
.setAtIndex(ValueLayout.JAVA_FLOAT, band.toLong(), gain)
}
public fun equaliser(band: Int): Float
Reads back a per-band equaliser gain.
public fun equaliser(band: Int): Float {
checkBand(band)
return IPLReflectionEffectParams.eq(segment)
.getAtIndex(ValueLayout.JAVA_FLOAT, band.toLong())
}
public fun setTrueAudioNext(device: TrueAudioNextDevice, slot: Int)
The TrueAudio Next device and slot, for ReflectionEffectType.TRUE_AUDIO_NEXT.
public fun setTrueAudioNext(device: TrueAudioNextDevice, slot: Int) {
IPLReflectionEffectParams.tanDevice(segment, device.liveHandle)
IPLReflectionEffectParams.tanSlot(segment, slot)
}
public fun applyShapeOf(effect: ReflectionEffect)
Copies effect's shape into the three fields the simulator does not fill in.
Call this once after allocating, and again if you recreate the effect.
public fun applyShapeOf(effect: ReflectionEffect) {
type = effect.type
channelCount = effect.channelCount
irSize = effect.irSize
}
public fun copySimulatedFrom(outputs: SimulationOutputs)
Takes the simulated reflection parameters from outputs, preserving the three shape
fields that describe the effect rather than the simulation.
This is the correct way to move a simulator's result into an effect: a blind struct copy would overwrite channelCount, irSize and type with whatever the simulator happened to leave there.
public fun copySimulatedFrom(outputs: SimulationOutputs) {
val savedType = type
val savedChannels = channelCount
val savedIrSize = irSize
segment.copyFrom(outputs.reflectionsSegment)
type = savedType
channelCount = savedChannels
irSize = savedIrSize
}
private fun checkBand(band: Int) {
if (band !in 0 until SteamAudio.NUM_BANDS) {
throw IndexOutOfBoundsException("band $band outside 0..<${SteamAudio.NUM_BANDS}")
}
}
}
public class ReflectionEffect private constructor
Renders the reflected sound of a source: the energy that reaches the listener after bouncing off geometry.
This is what makes a space audible as a space. In a cave, most of what you hear from a distant source is reflections, not the direct path.
Input and output
Input is the source's mono dry signal. Output is Ambisonic, with channelCount channels -- the reflected field arrives from all around, so it cannot be represented as a single direction. Decode it to stereo with an AmbisonicsDecodeEffect in binaural mode.
Choosing a type
ReflectionEffectType.CONVOLUTIONconvolves the full simulated impulse response. The default, and measurement says it should be. See below.ReflectionEffectType.PARAMETRICdrives a feedback delay network from the simulated RT60s. Around five times cheaper (67 us against 281 us median per block, measured), and the right choice for distant or background sources. It loses the early reflection detail -- 389 discrete early peaks against 515 -- and because the network is driven by reverb times that jump at the simulation rate rather than cross-fading, it can audibly glitch on a foreground source.ReflectionEffectType.HYBRIDconvolves the early reflections and goes parametric for the late tail. The premise is that this saves the cost of convolving a long tail. Measured, it does not: it is more expensive than plain convolution at every duration tested, because it pays for the convolution and the feedback network.
| duration | CONVOLUTION p95 | HYBRID p95 |
|---|---|---|
| 1 s | 221 us | 284 us |
| 3 s | 555 us | 622 us |
| 6 s | 1066 us | 1138 us |
| 10 s | 1764 us | 1823 us |
One source, order 1, 512-sample blocks at 48 kHz on a 10-core machine. There is no
crossover in that range, and the early-reflection structure is indistinguishable from
convolution's. Prefer ReflectionEffectType.CONVOLUTION unless you measure otherwise on
your own content.
- ReflectionEffectType.TRUE_AUDIO_NEXT is convolution on AMD GPU hardware.
Sharing work across sources with a mixer
With many sources, one reflection effect per source means one convolution per source. A ReflectionMixer lets each effect accumulate into a shared Ambisonic bus, so the expensive decode happens once rather than per source. Pass the same mixer to every apply, then call ReflectionMixer.apply once per block to read the mix out.
public class ReflectionEffect private constructor(
handle: MemorySegment,
/** The algorithm this effect was created with. */
public val type: ReflectionEffectType,
/** Number of Ambisonic output channels. */
public val channelCount: Int,
/** Impulse response length in samples. */
public val irSize: Int,
) : AudioEffect(handle) {
override fun releaseAction(): (MemorySegment) -> Unit = Phonon::iplReflectionEffectRelease
override val typeName: String get() = "ReflectionEffect"
public fun apply
Renders one block of reflections.
@param input the source's mono dry signal.
@param output an Ambisonic buffer with channelCount channels. Ignored when mixer is
given -- the effect accumulates into the mixer instead.
@param mixer when non-null, accumulate into this shared bus rather than writing
output. Read the result with ReflectionMixer.apply.
public fun apply(
params: ReflectionEffectParams,
input: AudioBuffer,
output: AudioBuffer,
mixer: ReflectionMixer? = null,
): AudioEffectState {
require(input.channelCount == 1) {
"a reflection effect takes mono input, got ${input.channelCount} channels"
}
if (mixer == null) {
require(output.channelCount == channelCount) {
"output needs $channelCount Ambisonic channels, got ${output.channelCount}"
}
}
return AudioEffectState.from(
Phonon.iplReflectionEffectApply(
liveHandle,
params.segment,
input.struct,
output.struct,
mixer.handleOrNull(),
),
)
}
override fun reset()
override fun reset() {
Phonon.iplReflectionEffectReset(liveHandle)
}
override val tailSize: Int
Samples of tail remaining.
In a long-decay space this is large -- potentially several seconds' worth -- which is exactly why tails must be drained rather than cut. See AudioEffect.
override val tailSize: Int
get() = Phonon.iplReflectionEffectGetTailSize(liveHandle)
override fun getTail(output: AudioBuffer): AudioEffectState = getTail(output, null)
public fun getTail(output: AudioBuffer, mixer: ReflectionMixer? = null): AudioEffectState
Renders one block of tail, optionally accumulating into mixer.
public fun getTail(output: AudioBuffer, mixer: ReflectionMixer? = null): AudioEffectState {
if (mixer == null) {
require(output.channelCount == channelCount) {
"output needs $channelCount Ambisonic channels, got ${output.channelCount}"
}
}
return AudioEffectState.from(
Phonon.iplReflectionEffectGetTail(liveHandle, output.struct, mixer.handleOrNull()),
)
}
public fun retain(): ReflectionEffect
Increments the reference count and returns an independently closeable wrapper.
public fun retain(): ReflectionEffect =
ReflectionEffect(Phonon.iplReflectionEffectRetain(liveHandle), type, channelCount, irSize)
public companion object
public companion object {
public operator fun invoke
Creates a reflection effect.
@param type the reflection algorithm. See the class documentation for how to choose.
@param duration the impulse response length in seconds. This must be at least the
maxDuration of the Simulator feeding it. Longer costs more, linearly, for
convolution.
@param ambisonicOrder Ambisonic order of the reflected field. Order 1 (4 channels)
is enough for a diffuse tail; order 2 gives a sharper spatial impression of early
reflections. Must match the simulator's maxOrder.
public operator fun invoke(
context: SteamAudioContext,
audioSettings: AudioSettings,
type: ReflectionEffectType = ReflectionEffectType.CONVOLUTION,
duration: Float = 2.0f,
ambisonicOrder: Int = 1,
): ReflectionEffect {
require(duration > 0f) { "duration must be positive, got $duration" }
require(ambisonicOrder >= 0) { "ambisonicOrder cannot be negative" }
val irSize = irSizeFor(audioSettings, duration)
val channelCount = SteamAudio.ambisonicChannelCount(ambisonicOrder)
val contextHandle = context.liveHandle
Arena.ofConfined().use { arena ->
val audio = audioSettings.allocate(arena)
val settings = IPLReflectionEffectSettings.allocate(arena)
IPLReflectionEffectSettings.type(settings, type.value)
IPLReflectionEffectSettings.irSize(settings, irSize)
IPLReflectionEffectSettings.numChannels(settings, channelCount)
return ReflectionEffect(
createHandle("iplReflectionEffectCreate") { out ->
Phonon.iplReflectionEffectCreate(contextHandle, audio, settings, out)
},
type,
channelCount,
irSize,
)
}
}
public fun irSizeFor(audioSettings: AudioSettings, durationSeconds: Float): Int
Impulse response length in samples for a given duration, as Steam Audio expects it.
public fun irSizeFor(audioSettings: AudioSettings, durationSeconds: Float): Int =
(durationSeconds * audioSettings.samplingRate).toInt().coerceAtLeast(1)
}
}
public class ReflectionMixer private constructor
A shared Ambisonic bus that several ReflectionEffects accumulate into.
With n sources, mixing in the Ambisonic domain and decoding once turns n decodes into
one. For convolution reflections this is the difference between a handful of sources and a
useful number of them.
for (source in sources) {
source.reflection.apply(source.reflectionParams, source.dry, unused, mixer)
}
mixer.apply(mixerParams, ambisonicBus) // read the accumulated field out
ambisonicDecode.apply(decodeParams, ambisonicBus, stereo)
A mixer must be created with the same settings as the effects feeding it.
public class ReflectionMixer private constructor(
handle: MemorySegment,
/** Number of Ambisonic channels on this bus. */
public val channelCount: Int,
) : SteamAudioObject(handle) {
override fun releaseAction(): (MemorySegment) -> Unit = Phonon::iplReflectionMixerRelease
override val typeName: String get() = "ReflectionMixer"
public fun apply(params: ReflectionEffectParams, output: AudioBuffer): AudioEffectState
Reads the accumulated mix into output and clears the bus for the next block.
Call once per block, after every contributing effect has been applied.
public fun apply(params: ReflectionEffectParams, output: AudioBuffer): AudioEffectState {
require(output.channelCount == channelCount) {
"output needs $channelCount Ambisonic channels, got ${output.channelCount}"
}
return AudioEffectState.from(
Phonon.iplReflectionMixerApply(liveHandle, params.segment, output.struct),
)
}
public fun reset()
Clears the bus.
public fun reset() {
Phonon.iplReflectionMixerReset(liveHandle)
}
public fun retain(): ReflectionMixer
Increments the reference count and returns an independently closeable wrapper.
public fun retain(): ReflectionMixer =
ReflectionMixer(Phonon.iplReflectionMixerRetain(liveHandle), channelCount)
public companion object
public companion object {
public operator fun invoke
Creates a mixer.
The arguments must match the ReflectionEffects that will feed it.
public operator fun invoke(
context: SteamAudioContext,
audioSettings: AudioSettings,
type: ReflectionEffectType = ReflectionEffectType.CONVOLUTION,
duration: Float = 2.0f,
ambisonicOrder: Int = 1,
): ReflectionMixer {
val channelCount = SteamAudio.ambisonicChannelCount(ambisonicOrder)
val contextHandle = context.liveHandle
Arena.ofConfined().use { arena ->
val audio = audioSettings.allocate(arena)
val settings = IPLReflectionEffectSettings.allocate(arena)
IPLReflectionEffectSettings.type(settings, type.value)
IPLReflectionEffectSettings.irSize(
settings,
ReflectionEffect.irSizeFor(audioSettings, duration),
)
IPLReflectionEffectSettings.numChannels(settings, channelCount)
return ReflectionMixer(
createHandle("iplReflectionMixerCreate") { out ->
Phonon.iplReflectionMixerCreate(contextHandle, audio, settings, out)
},
channelCount,
)
}
}
}
}