PatSpatialPlacementDocs.kt
src/jvmMain/kotlin/afield/audio/synth/PatSpatialPlacementDocs.kt · 149 lines
@file:afield.runtime.completion.CompletionCategory("Audio")
package afield.audio.synth
import afield.audio.engine.WavFloatArrayWriter
import java.io.File
import kotlin.math.sqrt
FIELD_TEST id: synth.pat-spatial.placement runner: jvm-main entrypoint: afield.audio.synth.PatSpatialPlacementDocs tags: [smoke, audio, synth, steamaudio, docs, wasm, dough, cpu] timeoutSec: 300 expect: exitCode: 0 stdoutContains: - "pat spatial placement docs rendered"
object PatSpatialPlacementDocs
The two placed, binaural clips on pat_spatial.md: the page's own patSpatial and
showPatSpatial examples, through Steam Audio's HRTF, with a still listener.
Rendered offline exactly as PatSpatialHeadTrackedDocs renders the head-tracked clip: the real
patSpatial launch, its render thread filling the ring, and the bank pulled one block at a time,
so the file is what the launch would play. Needs libphonon (./gradlew fetchSteamAudioNatives).
object PatSpatialPlacementDocs {
const val SR = 48_000
patSpatial's default rate.
Frames pulled from the bank per block.
private const val BLOCK = 512
class Figure(val name: String, val seconds: Double, val cps: Double, val maxSources: Int, val pattern: () -> PatPattern<String>)
One published clip: a pattern from the page, and how long to play it.
class Figure(val name: String, val seconds: Double, val cps: Double, val maxSources: Int, val pattern: () -> PatPattern<String>)
val FIGURES: List<Figure>
The figures, in page order.
val FIGURES: List<Figure> = listOf(
// "Placing a source": whole cycles alternate between the kit and hats groups.
Figure("kit-hats", seconds = 6.0, cps = 2.0, maxSources = 2) {
pat("bd ~ sd ~, hh*8")
.with(
paramOf("spat", pat("<kit hats>")),
param("az", sine().range(-70.0, 70.0)),
param("dist", "1 3 1 6"),
)
},
// "Hearing it in a box": a four-note line moving between two groups.
Figure("four-note-line", seconds = 6.0, cps = 2.0, maxSources = 2) {
pat("60 64 67 71")
.with(
paramOf("spat", pat("a b a b")),
param("az", sine().range(-85.0, 85.0)),
paramOf("dist", pat("1.2 3 1.2 5")),
param("lpf", 2200),
)
},
)
private val byName = FIGURES.associateBy { it.name }
fun figure(name: String): Figure
Looks a figure up by name, with a message that lists the alternatives.
fun figure(name: String): Figure =
byName[name] ?: error("unknown PatSpatialPlacementDocs figure '$name'; known: ${byName.keys.sorted()}")
fun render(name: String): FloatArray
Renders figure name to interleaved stereo at SR.
fun render(name: String): FloatArray {
val f = figure(name)
val total = (f.seconds * SR).toInt()
val out = FloatArray(total * 2)
val launch = patSpatial(f.pattern(), cps = f.cps, sampleRate = SR, maxSources = f.maxSources,
listenerPoseAt = { HeadPose() })
try {
check(launch.awaitReady()) { "the spatial room never became ready" }
val ctx = SynthContext(SR.toFloat(), BLOCK)
val l = FloatArray(BLOCK)
val r = FloatArray(BLOCK)
var at = 0
while (at < total) {
while (launch.room.ring.fill < BLOCK) {
launch.room.failure?.let { throw IllegalStateException("render thread failed", it) }
Thread.sleep(1L)
}
java.util.Arrays.fill(l, 0f)
java.util.Arrays.fill(r, 0f)
ctx.cycleId = at.toLong() / BLOCK + 1
ctx.time = at.toFloat() / SR
launch.bank.render(l, r, BLOCK, ctx)
val n = minOf(BLOCK, total - at)
for (i in 0 until n) {
out[2 * (at + i)] = l[i]
out[2 * (at + i) + 1] = r[i]
}
at += BLOCK
}
val underruns = launch.room.ring.underruns
check(underruns == 0L) { "$underruns frames of underrun: the file would not be the render" }
} finally {
launch.close()
}
return out
}
fun renderWav(name: String, outPath: String)
Renders name to a stereo 16-bit WAV: the docs fence's entry point.
fun renderWav(name: String, outPath: String) {
val interleaved = render(name)
val frames = interleaved.size / 2
val left = FloatArray(frames) { interleaved[2 * it] }
val right = FloatArray(frames) { interleaved[2 * it + 1] }
require(left.any { it != 0f } || right.any { it != 0f }) { "figure '$name' rendered silence" }
WavFloatArrayWriter.writeStereo16(outPath, left, right, SR, normalizePeak = 0.9f)
}
RMS of channel channel of interleaved over frames from until to.
private fun rms(interleaved: FloatArray, channel: Int, from: Int, to: Int): Double {
var sum = 0.0
for (i in from until to) {
val v = interleaved[2 * i + channel].toDouble()
sum += v * v
}
return sqrt(sum / (to - from).coerceAtLeast(1))
}
fun main(args: Array<String>)
@JvmStatic
fun main(args: Array<String>) {
val outDir = File(args.getOrNull(0) ?: File(System.getProperty("java.io.tmpdir"), "pat-spatial-placement").path)
outDir.mkdirs()
FIGURES.forEach { f ->
val a = render(f.name)
val frames = a.size / 2
// Left/right balance per quarter cycle: a source that sweeps in azimuth swings this. A
// window of a whole cycle would average one sweep of `sine()` back to the middle.
val half = (SR / f.cps / 4).toInt()
val balances = (0 until frames / half).map { w ->
rms(a, 0, w * half, (w + 1) * half) / rms(a, 1, w * half, (w + 1) * half).coerceAtLeast(1e-9)
}
println(" %-16s L/R per quarter cycle: %s".format(f.name, balances.joinToString(" ") { "%.2f".format(it) }))
check(balances.max() / balances.min() > 1.5) { "${f.name}: the image does not move across the ears" }
val file = File(outDir, "${f.name}.wav")
renderWav(f.name, file.absolutePath)
println(" wrote ${file.absolutePath}")
}
println("pat spatial placement docs rendered: ${FIGURES.size} figure(s)")
}
}