FaustSmokeTest.kt
src/jvmMain/kotlin/afield/audio/synth/FaustSmokeTest.kt · 393 lines
package afield.audio.synth
import kotlin.math.abs
import kotlin.math.max
FIELD_TEST id: synth.faust.smoke runner: jvm-main entrypoint: afield.audio.synth.FaustSmokeTest tags: [smoke, synth, faust, audio, wasm, cpu] timeoutSec: 600 expect: exitCode: 0 stdoutContains: - "Faust smoke tests passed"
The Faust binding, end to end, without audio hardware.
What this is really guarding is the contract with a vendored third-party compiler: nothing here
is Field's own code being clever, it is a 3MB WebAssembly build of libfaust behaving the way
FaustEngine's KDoc claims it does. Every assertion below corresponds to something that was
discovered the hard way and would break silently on a version bump:
- the Emscripten environment shim still boots (a
libfaust-wasm.jsthat detects Node would callrequire('fs')and die), createDSPFactorystill returns its wasm indataas an embind vector rather thancode,- a bad program still reports
name:line : ERROR : ..., which is the whole diagnostics story, generateAuxFiles(-svg)still throws after writing the diagram,- and the UI JSON still carries
index, which is the only reason a slider can move a parameter.
The timeout is generous: the first compile in a process pays 13–22s of Truffle warm-up.
object FaustSmokeTest
object FaustSmokeTest {
private const val SR = 48_000
fun main(args: Array<String>)
@JvmStatic
fun main(args: Array<String>) {
val ok = try {
runTests()
} finally {
// Leave nothing holding the JVM open; see FaustEngine.shutdown.
FaustEngine.shutdown()
}
check(ok) { "Faust smoke tests failed" }
println("Faust smoke tests passed")
}
fun runTests(): Boolean
fun runTests(): Boolean {
val report = Reporter()
println("header stripping")
checkHeader(report)
println("compiler")
val program = checkCompile(report)
println("ui metadata")
if (program != null) checkUi(report, program)
println("diagnostics")
checkDiagnostics(report)
println("rendering")
if (program != null) checkRendering(report, program)
println("mono fan-out")
checkMono(report)
println("effects (inputs)")
checkEffect(report)
println("block diagram")
checkDiagram(report)
report.summarise()
return report.failures == 0
}
header
private fun checkHeader(report: Reporter) {
val (body, offset) = stripFaustHeader("\n#faust\nprocess = _;\n")
report.check("header removed", !body.contains("#faust")) { body }
// Two lines went: the leading blank from the Kotlin """ and the marker itself.
report.check("offset counts the blank line", offset == 2) { "offset=$offset" }
report.check("body survives", body.trim() == "process = _;") { body }
val (plain, zero) = stripFaustHeader("process = _;")
report.check("no header is left alone", plain == "process = _;" && zero == 0) { "offset=$zero" }
}
compiling
source — 46 lines, from line 98
private val VOICE = """
import("stdfaust.lib");
freq = hslider("freq [unit:Hz] [scale:log]", 440, 50, 2000, 0.01);
cutoff = hslider("cutoff [unit:Hz]", 1200, 50, 8000, 1);
gain = hslider("gain", 0.3, 0, 1, 0.001);
level = hbargraph("level", 0, 1);
process = os.sawtooth(freq) : fi.lowpass(2, cutoff) * gain
: (_ <: attach(_, abs : max ~ *(0.999) : level)) <: _,_;
""".trimIndent()
private fun checkCompile(report: Reporter): FaustProgram? {
val version = FaustEngine.libfaustVersion()
report.check(
"libfaust is the vendored build",
version == FaustEngine.EXPECTED_LIBFAUST_VERSION
) { "loaded $version, PROVENANCE.md says ${FaustEngine.EXPECTED_LIBFAUST_VERSION}" }
val firstMs = System.currentTimeMillis()
val program = try {
FaustEngine.compile("smoke", VOICE)
} catch (e: FaustCompileError) {
report.check("voice compiles", false) { "${e.faustMessage} (line ${e.line})" }
return null
}
val elapsed = System.currentTimeMillis() - firstMs
println(" first compile ${elapsed}ms, ${program.wasm.size} bytes of wasm")
report.check("wasm was produced", program.wasm.size > 512) { "${program.wasm.size} bytes" }
report.check(
"wasm is a WebAssembly module",
program.wasm.size > 4 &&
program.wasm[0] == 0x00.toByte() && program.wasm[1] == 0x61.toByte() &&
program.wasm[2] == 0x73.toByte() && program.wasm[3] == 0x6D.toByte()
) { program.wasm.take(4).joinToString(" ") { "%02x".format(it) } }
report.check("stereo out", program.outputs == 2) { "outputs=${program.outputs}" }
report.check("no inputs", program.inputs == 0) { "inputs=${program.inputs}" }
// The cache is what makes a re-run of an unchanged box free.
val cachedStart = System.nanoTime()
val again = FaustEngine.compile("smoke", VOICE)
val cachedMs = (System.nanoTime() - cachedStart) / 1_000_000
report.check("second compile is cached", cachedMs < 50) { "${cachedMs}ms" }
report.check("cached program is the same", again.wasm.contentEquals(program.wasm)) { "differs" }
return program
}
ui
source — 41 lines, from line 147
private fun checkUi(report: Reporter, program: FaustProgram) {
val ui = program.ui
val byLabel = ui.controls.associateBy { it.label }
report.check("three sliders and a meter", ui.controls.size == 4) {
ui.controls.joinToString { "${it.type}:${it.label}" }
}
report.check("writable excludes the meter", ui.writable.size == 3) {
ui.writable.joinToString { it.label }
}
report.check("meter is read-only", ui.meters.singleOrNull()?.label == "level") {
ui.meters.joinToString { it.label }
}
val freq = byLabel["freq"]
report.check("freq exists", freq != null) { byLabel.keys.toString() }
if (freq != null) {
report.check("freq range", freq.min == 50.0 && freq.max == 2000.0) { "${freq.min}..${freq.max}" }
report.check("freq default", freq.init == 440.0) { "${freq.init}" }
report.check("freq step", freq.step == 0.01) { "${freq.step}" }
report.check("freq unit parsed", freq.unit == "Hz") { "${freq.unit}" }
report.check("freq scale parsed", freq.scale == FaustScale.LOG) { "${freq.scale}" }
report.check("freq display label", freq.displayLabel == "freq (Hz)") { freq.displayLabel }
// Faust wraps every program in an implicit group named after the module. Field's module
// name is the box id, so if that wrapper leaked into the label every slider in the UI
// would read `box_a1b2c3/freq`.
report.check("implicit outer group is dropped", freq.groupPath.isEmpty()) {
freq.groupPath.toString()
}
report.check("qualified label is bare", freq.qualifiedLabel == "freq") { freq.qualifiedLabel }
// Without an index there is no way to move the parameter at all.
report.check("freq has a memory index", freq.index >= 0) { "${freq.index}" }
}
val cutoff = byLabel["cutoff"]
report.check("cutoff is linear by default", cutoff?.scale == FaustScale.LINEAR) { "${cutoff?.scale}" }
// Every writable control must have a distinct index, or two sliders would fight.
val indices = ui.writable.map { it.index }
report.check("indices are distinct", indices.toSet().size == indices.size) { indices.toString() }
}
diagnostics
private fun checkDiagnostics(report: Reporter) {
val bad = "import(\"stdfaust.lib\");\nprocess = os.sawtoothX(440);\n"
try {
FaustEngine.compile("badprog", bad)
report.check("a bad program fails", false) { "it compiled" }
} catch (e: FaustCompileError) {
report.check("error names the line", e.line == 2) { "line=${e.line} raw=${e.raw}" }
report.check(
"error names the symbol",
e.faustMessage.contains("sawtoothX")
) { e.faustMessage }
report.check(
"error message is not the Emscripten wrapper",
!e.faustMessage.contains("Exception catching")
) { e.faustMessage }
}
// The compiler must still work after an error — it holds state that needs cleaning up.
val after = try {
FaustEngine.compile("recovered", "process = 0.0;"); true
} catch (e: FaustCompileError) {
false
}
report.check("compiler recovers after an error", after) { "second compile failed" }
// A syntax error, which reports differently from an unbound symbol.
try {
FaustEngine.compile("syntaxerr", "process = (((;\n")
report.check("a syntax error fails", false) { "it compiled" }
} catch (e: FaustCompileError) {
report.check("syntax error has a message", e.faustMessage.isNotBlank()) { e.raw }
}
}
rendering
source — 78 lines, from line 227
private fun checkRendering(report: Reporter, program: FaustProgram) {
val dsp = FaustDsp(program, SR)
try {
report.check("dsp reports stereo", dsp.numOutputs == 2) { "${dsp.numOutputs}" }
val block = 128
val l = FloatArray(block)
val r = FloatArray(block)
dsp.render(l, r, block)
val peak = l.maxOf { abs(it) }
report.check("it makes a sound", peak > 1e-3) { "peak=$peak" }
report.check("nothing is NaN", l.none { it.isNaN() } && r.none { it.isNaN() }) { "NaN present" }
report.check("it stays in range", l.all { abs(it) <= 1.5f }) { "peak=$peak" }
// A gain of zero must actually silence it: proof the parameter reached the DSP.
val gain = program.ui.writable.first { it.label == "gain" }
dsp.setParam(gain, 0.0)
dsp.render(l, r, block) // this block applies the queued write
dsp.render(l, r, block) // and this one is fully at the new value
val silenced = l.maxOf { abs(it) }
report.check("gain=0 silences it", silenced < 1e-6) { "peak=$silenced" }
dsp.setParam(gain, 0.3)
dsp.render(l, r, block)
dsp.render(l, r, block)
report.check("gain restores", l.maxOf { abs(it) } > 1e-3) { "peak=${l.maxOf { abs(it) }}" }
// The meter should have seen the signal.
val meter = program.ui.meters.first()
report.check("meter reads back", dsp.meterValue(meter.index) > 0f) {
"level=${dsp.meterValue(meter.index)}"
}
// A block larger than one compute call, to exercise the chunking loop.
val big = FaustDsp.MAX_FRAMES + 37
val bl = FloatArray(big)
val br = FloatArray(big)
dsp.render(bl, br, big)
report.check("a long block renders throughout", abs(bl[big - 1]) > 0f || abs(bl[big - 2]) > 0f) {
"tail is silent"
}
report.check("no NaN in a long block", bl.none { it.isNaN() }) { "NaN present" }
// The measurement that justifies doing this in the JVM at all.
val blocks = 4000
val warm = FloatArray(block)
repeat(500) { dsp.render(warm, warm, block) }
val start = System.nanoTime()
repeat(blocks) { dsp.render(l, r, block) }
val wallMs = (System.nanoTime() - start) / 1e6
val audioMs = blocks.toDouble() * block / SR * 1000.0
val factor = audioMs / wallMs
println(" render: ${"%.1f".format(factor)}x realtime (${"%.1f".format(wallMs * 1000 / blocks)}us per $block-frame block)")
// Deliberately loose: this is a smoke test on unknown hardware, and the measured figure
// was 353x. Anything below 10x would mean something is structurally wrong.
report.check("renders comfortably faster than realtime", factor > 10.0) {
"${"%.1f".format(factor)}x"
}
} finally {
dsp.close()
}
}
private fun checkMono(report: Reporter) {
val program = FaustEngine.compile("monoprog", "import(\"stdfaust.lib\");\nprocess = os.osc(220) * 0.2;")
report.check("mono program has one output", program.outputs == 1) { "${program.outputs}" }
val dsp = FaustDsp(program, SR)
try {
val l = FloatArray(64)
val r = FloatArray(64)
dsp.render(l, r, 64)
report.check("mono is audible", l.maxOf { abs(it) } > 1e-3) { "peak=${l.maxOf { abs(it) }}" }
report.check("mono is copied to both channels", l.contentEquals(r)) { "channels differ" }
} finally {
dsp.close()
}
}
A program with inputs — an effect rather than a synth.
This is the only check on the write half of the WebAssembly boundary. Outputs come out through
one bulk readBuffer per channel, but the polyglot API has no bulk write, so inputs go in a
float at a time through writeBufferFloat — a completely separate path, and one that a synth
program never touches. A gain of exactly 0.5 makes the assertion arithmetic rather than
approximate: every output sample must be half its input, and the two channels must not be
swapped or aliased.
source — 41 lines, from line 316
private fun checkEffect(report: Reporter) {
val program = FaustEngine.compile(
"effectprog",
"process = _,_ : *(0.5), *(0.25);"
)
report.check("effect declares two inputs", program.inputs == 2) { "${program.inputs}" }
report.check("effect declares two outputs", program.outputs == 2) { "${program.outputs}" }
val dsp = FaustDsp(program, SR)
try {
report.check("dsp reports the inputs", dsp.numInputs == 2) { "${dsp.numInputs}" }
val n = 64
// Distinct, non-constant, and different per channel, so a silent buffer, a swap or one
// channel aliased onto the other all show up as a failure rather than as a pass.
val inL = FloatArray(n) { 0.8f * kotlin.math.sin(it * 0.11f) }
val inR = FloatArray(n) { -0.6f * kotlin.math.cos(it * 0.07f) }
val outL = FloatArray(n)
val outR = FloatArray(n)
dsp.render(outL, outR, n, inL, inR)
var worstL = 0f
var worstR = 0f
for (i in 0 until n) {
worstL = max(worstL, abs(outL[i] - inL[i] * 0.5f))
worstR = max(worstR, abs(outR[i] - inR[i] * 0.25f))
}
report.check("left channel is the input halved", worstL < 1e-6f) { "worst error $worstL" }
report.check("right channel is the input quartered", worstR < 1e-6f) { "worst error $worstR" }
report.check("it is not silent", outL.maxOf { abs(it) } > 0.1f) { "peak ${outL.maxOf { abs(it) }}" }
// No input given: an effect must go quiet rather than reuse the last block or read rubbish
// out of whatever happened to be in wasm memory.
dsp.render(outL, outR, n)
report.check("no input means silence", outL.all { it == 0f } && outR.all { it == 0f }) {
"peak ${outL.maxOf { abs(it) }}"
}
} finally {
dsp.close()
}
}
diagram
private fun checkDiagram(report: Reporter) {
val svgs = FaustEngine.blockDiagram("diagprog", VOICE)
report.check("a diagram was produced", svgs.isNotEmpty()) { "none" }
val process = svgs["process.svg"]
report.check("process.svg is there", process != null) { svgs.keys.joinToString() }
if (process != null) {
report.check("it is really svg", process.contains("<svg")) { process.take(60) }
report.check("it is not empty", process.length > 1000) { "${process.length} chars" }
}
println(" diagram: ${svgs.size} files, process.svg ${process?.length ?: 0} chars")
}
reporting
private class Reporter {
var failures = 0
private var checks = 0
fun check(what: String, ok: Boolean, detail: () -> String = { "" }) {
checks++
if (ok) {
println(" ok $what")
} else {
failures++
println(" FAIL $what — ${detail()}")
}
}
fun summarise() {
println("$checks checks, $failures failed")
}
}
}