HrtfProbe.kt
src/jvmMain/kotlin/afield/examples/steamaudio/HrtfProbe.kt · 150 lines
package afield.examples.steamaudio
import afield.audio.spatial.steamaudio.AudioBuffer
import afield.audio.spatial.steamaudio.AudioSettings
import afield.audio.spatial.steamaudio.BinauralEffect
import afield.audio.spatial.steamaudio.BinauralEffectParams
import afield.audio.spatial.steamaudio.Hrtf
import afield.audio.spatial.steamaudio.HrtfInterpolation
import afield.audio.spatial.steamaudio.SteamAudioContext
import java.lang.foreign.Arena
import java.lang.foreign.ValueLayout
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.log10
import kotlin.math.sin
import kotlin.math.sqrt
fun main()
Measures the binaural effect directly: feeds a single impulse from a series of azimuths and reports the per-ear arrival delay and level.
This is the diagnostic that actually pins down whether spatialisation is correct, because an impulse is broadband and aperiodic -- unlike a tone, whose periodicity makes cross-correlation ambiguous and whose narrow spectrum understates interaural level difference.
What a correct HRTF should show:
- Sign: for a source on the right, the right ear leads (smaller delay) and is louder.
- ITD magnitude: up to roughly 600-700 microseconds at full lateral, which is the time for sound to travel the extra distance around a head.
- Symmetry: +90 and -90 should mirror each other.
- Median plane: at 0 and 180 degrees, both ears should be near-identical.
body — 99 lines, from line 35
fun main() {
val settings = AudioSettings(samplingRate = 48_000, frameSize = 1024)
SteamAudioContext().use { context ->
Arena.ofShared().use { arena ->
Hrtf.default(context, settings).use { hrtf ->
BinauralEffect(context, settings, hrtf).use { binaural ->
val input = AudioBuffer(arena, 1, settings.frameSize)
val output = AudioBuffer(arena, 2, settings.frameSize)
val params = BinauralEffectParams(
arena,
hrtf = hrtf,
// NEAREST avoids blending two measurements together, so the impulse
// response we measure is a single HRTF tap rather than a mixture.
interpolation = HrtfInterpolation.NEAREST,
trackPeakDelays = true,
)
val left = FloatArray(settings.frameSize)
val right = FloatArray(settings.frameSize)
println("Binaural effect probe -- impulse response per azimuth")
println("azimuth is measured clockwise from straight ahead; +90 is the right ear side")
println()
println(" azimuth peak L peak R ILD dB onset L onset R ITD us leads")
println(" " + "-".repeat(78))
for (azimuthDegrees in intArrayOf(0, 30, 60, 90, 120, 150, 180, -150, -120, -90, -60, -30)) {
val radians = azimuthDegrees * PI / 180.0
// Unit direction in the listener's frame: +x right, -z ahead.
val dx = sin(radians).toFloat()
val dz = (-cos(radians)).toFloat()
binaural.reset()
params.setDirection(dx, 0f, dz)
// A single unit impulse at the start of an otherwise silent block.
input.clear()
input[0, 0] = 1.0f
output.clear()
binaural.apply(params, input, output)
output.readChannel(0, left)
output.readChannel(1, right)
val peakL = left.maxOf { abs(it) }
val peakR = right.maxOf { abs(it) }
val onsetL = onsetIndex(left)
val onsetR = onsetIndex(right)
val itdSamples = onsetL - onsetR // positive => right ear leads
val itdMicros = itdSamples.toDouble() / settings.samplingRate * 1e6
val ild = 20.0 * log10((peakR + 1e-12) / (peakL + 1e-12))
val leads = when {
itdSamples > 0 -> "right"
itdSamples < 0 -> "left"
else -> "--"
}
println(
"%8d %8.4f %8.4f %+9.2f %9d %9d %+8.0f %s".format(
azimuthDegrees, peakL, peakR, ild, onsetL, onsetR, itdMicros, leads,
),
)
}
println()
println("peak delays reported by Steam Audio for the last direction: " +
"L=${params.leftPeakDelay} R=${params.rightPeakDelay}")
// Energy check across the sphere: a well-formed HRTF should not change
// total loudness wildly with direction.
println()
var minEnergy = Double.MAX_VALUE
var maxEnergy = 0.0
for (azimuthDegrees in 0 until 360 step 15) {
val radians = azimuthDegrees * PI / 180.0
binaural.reset()
params.setDirection(sin(radians).toFloat(), 0f, (-cos(radians)).toFloat())
input.clear()
input[0, 0] = 1.0f
output.clear()
binaural.apply(params, input, output)
output.readChannel(0, left)
output.readChannel(1, right)
val energy = sqrt(left.sumOf { it.toDouble() * it } + right.sumOf { it.toDouble() * it })
minEnergy = minOf(minEnergy, energy)
maxEnergy = maxOf(maxEnergy, energy)
}
println(
"total energy across 24 azimuths: min %.4f max %.4f spread %.2f dB"
.format(minEnergy, maxEnergy, 20 * log10(maxEnergy / minEnergy)),
)
}
}
}
}
}
Index of the first sample exceeding a fraction of the signal's peak.
A fixed threshold relative to the peak is a reasonable onset estimator for an impulse response, and is far more robust than cross-correlation on a periodic signal.
private fun onsetIndex(samples: FloatArray, fraction: Float = 0.1f): Int {
val peak = samples.maxOf { abs(it) }
if (peak == 0f) return -1
val threshold = peak * fraction
for (index in samples.indices) {
if (abs(samples[index]) >= threshold) return index
}
return -1
}