Skip to content

FaustSoundDocs.kt

src/jvmMain/kotlin/afield/audio/synth/FaustSoundDocs.kt · 170 lines

@file:afield.runtime.completion.CompletionCategory("Audio")
package afield.audio.synth

import afield.audio.engine.WavFloatArrayWriter
import java.io.File
import kotlin.math.abs

FIELD_TEST id: synth.faust.single.sound runner: jvm-main entrypoint: afield.audio.synth.FaustSoundDocs tags: [smoke, synth, faust, audio, wav, docs, wasm, cpu] timeoutSec: 600 expect: exitCode: 0 stdoutContains: - "faust sound docs rendered"

object FaustSoundDocs

The single-program clips on faust.md: the sawtooth through a low-pass with its cutoff moved halfway through, and the detuned pair of sawtooths dry and through zita reverb.

Each renders the page's own program through the same FaustDsp and FaustNode a faust(...) launch plays, in 128-frame blocks, with no audio device. FaustPolySoundDocs does the same for the polyphonic clips.

object FaustSoundDocs {

const val SR = 48_000

Sample rate of every clip; zita's own fs argument in REVERB says 48000 too.

const val BLOCK = 128

Frames per block, the audio engine's.

val LOWPASS: String

The page's first program, without the #faust marker a box needs.

    val LOWPASS: String = """
        import("stdfaust.lib");
        freq   = hslider("freq [unit:Hz] [scale:log]", 220, 50, 2000, 0.01);
        cutoff = hslider("cutoff [unit:Hz]", 1200, 50, 8000, 1);
        gain   = hslider("gain", 0.2, 0, 1, 0.001);
        process = os.sawtooth(freq) : fi.lowpass(2, cutoff) * gain <: _,_;
    """.trimIndent()

val REVERB: String

The page's effect program.

    val REVERB: String = """
        import("stdfaust.lib");
        room = hslider("room", 0.5, 0, 1, 0.01);
        process = _,_ : re.zita_rev1_stereo(20, 200, 2000, 3.0, 2.0, 48000) : *(room), *(room);
    """.trimIndent()

const val LOWPASS_HALF_SECONDS = 2.0

Seconds the low-pass clip plays at each cutoff.

const val LOWER_CUTOFF_HZ = 300.0

The cutoff the second half of the low-pass clip moves to, in Hz.

const val DRY_SECONDS = 2.0

Seconds the dry pair plays before it stops, in both reverb clips.

const val REVERB_SECONDS = 5.0

Length of each reverb clip: the dry pair, then its tail.

val FIGURES = listOf("saw-lowpass", "reverb-dry", "reverb-wet")

The clips, by the name their fences ask for.

The page's dry input: two sawtooths half a hertz apart.

    private fun dryPair(): StereoRenderable = StereoSignal(SawOsc(Param(110f)) * 0.2f, SawOsc(Param(110.5f)) * 0.2f)

inner for its first seconds, then silence: the pair stops so the tail can be heard.

    private class Stops(val inner: StereoRenderable, val seconds: Double) : StereoRenderable {
        override fun render(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) {
            if (ctx.time < seconds) inner.render(outL, outR, numFrames, ctx)
        }
    }

Pulls node for seconds in blocks, calling atBlock with each block's start time first.

    private fun pull(node: StereoRenderable, seconds: Double, atBlock: (Double) -> Unit = {}): Pair<FloatArray, FloatArray> {
        val frames = (seconds * SR).toInt()
        val left = FloatArray(frames)
        val right = FloatArray(frames)
        val ctx = SynthContext(SR.toFloat(), BLOCK)
        val l = FloatArray(BLOCK)
        val r = FloatArray(BLOCK)
        var at = 0
        while (at < frames) {
            java.util.Arrays.fill(l, 0f)
            java.util.Arrays.fill(r, 0f)
            ctx.cycleId = at.toLong() / BLOCK + 1
            ctx.time = at.toFloat() / SR
            atBlock(ctx.time.toDouble())
            node.render(l, r, BLOCK, ctx)
            val n = minOf(BLOCK, frames - at)
            System.arraycopy(l, 0, left, at, n)
            System.arraycopy(r, 0, right, at, n)
            at += BLOCK
        }
        return left to right
    }

fun render(name: String): Pair<FloatArray, FloatArray>

Renders clip name as left and right channels.

    fun render(name: String): Pair<FloatArray, FloatArray> = when (name) {
        "saw-lowpass" -> {
            val program = FaustEngine.compile("faustSoundDocsLowpass", LOWPASS)
            val cutoff = program.ui.writable.first { it.label.startsWith("cutoff") }
            FaustDsp(program, SR).use { dsp ->
                var moved = false
                pull(FaustNode(dsp), 2 * LOWPASS_HALF_SECONDS) { t ->
                    if (!moved && t >= LOWPASS_HALF_SECONDS) { dsp.setParam(cutoff, LOWER_CUTOFF_HZ); moved = true }
                }
            }
        }
        "reverb-dry" -> pull(Stops(dryPair(), DRY_SECONDS), REVERB_SECONDS)
        "reverb-wet" -> {
            val program = FaustEngine.compile("faustSoundDocsReverb", REVERB)
            FaustDsp(program, SR).use { dsp -> pull(FaustNode(dsp, Stops(dryPair(), DRY_SECONDS)), REVERB_SECONDS) }
        }
        else -> error("unknown FaustSoundDocs figure '$name'; known: $FIGURES")
    }

fun renderWav(name: String, outPath: String)

Offline-renders clip name to a stereo 16-bit WAV at outPath: the docs fence's entry point.

    fun renderWav(name: String, outPath: String) {
        val (left, right) = render(name)
        require(left.any { it != 0f } || right.any { it != 0f }) { "figure '$name' rendered silence" }
        WavFloatArrayWriter.writeStereo16(outPath, left, right, SR, normalizePeak = 0.9f)
    }

    private fun rms(x: FloatArray, from: Int, to: Int): Double =
        Math.sqrt((from until to).sumOf { x[it].toDouble() * x[it] } / (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"), "faust-sound").path)
        outDir.mkdirs()
        try {
            val (saw, _) = render("saw-lowpass")
            val half = (LOWPASS_HALF_SECONDS * SR).toInt()
            // Brightness as the RMS of the first difference over the RMS: a differentiator's gain rises
            // with frequency, so this falls when the filter takes the upper harmonics away.
            fun brightness(from: Int, to: Int): Double {
                val d = FloatArray(to - from - 1) { saw[from + it + 1] - saw[from + it] }
                return rms(d, 0, d.size) / rms(saw, from, to)
            }
            val before = brightness(half / 2, half)
            val after = brightness(half + half / 2, 2 * half)
            println("  saw-lowpass: brightness %.4f at 1200 Hz cutoff, %.4f at %.0f Hz".format(before, after, LOWER_CUTOFF_HZ))
            check(after < before * 0.8) { "moving the cutoff down did not darken the sound" }

            val (dry, _) = render("reverb-dry")
            val (wet, wetR) = render("reverb-wet")
            val tailFrom = (DRY_SECONDS * SR).toInt() + SR / 2
            val tailTo = tailFrom + SR / 2
            println("  reverb tail (0.5-1 s after the pair stops): dry rms %.5f, wet rms %.5f".format(rms(dry, tailFrom, tailTo), rms(wet, tailFrom, tailTo)))
            check(rms(dry, tailFrom, tailTo) == 0.0) { "the dry pair did not stop" }
            check(rms(wet, tailFrom, tailTo) > 1e-3) { "the reverb has no tail" }
            check((0 until wet.size).any { abs(wet[it] - wetR[it]) > 1e-4 }) { "the reverb output is not stereo" }

            FIGURES.forEach { name ->
                val file = File(outDir, "$name.wav")
                renderWav(name, file.absolutePath)
                println("  wrote ${file.absolutePath}")
            }
        } finally {
            FaustEngine.shutdown()
        }
        println("faust sound docs rendered: ${FIGURES.size} figure(s)")
    }
}