Skip to content

FaustPattern.kt

src/jvmMain/kotlin/afield/audio/synth/FaustPattern.kt · 1005 lines

@file:afield.runtime.completion.CompletionCategory("Synthesis")

package afield.audio.synth

import afield.document.graph.Box
import afield.runtime.execution.ReturnValueRegistry
import afield.runtime.lifecycle.RunsAt
import afield.ui.output.controlGroup
import afield.ui.output.displayOpen
import afield.runtime.lifecycle.inMain
import afield.document.graph.registerLaunchJob
import afield.runtime.lifecycle.returnValueRegistration
import afield.ui.visualization.showSignals
import kotlinx.coroutines.Job
import kotlinx.coroutines.yield
import java.io.Closeable
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToLong
import kotlin.math.sqrt

A pattern plays a Faust program.

The join between two things that each need nothing added to them. Field's pattern language, mini-notation over the Pat algebra in rational cycle time, says when and what pitch. A compiled Faust program is one always-on DSP with sliders. This file is small because neither side is modified and nothing is recompiled.

The whole conversion is one function, faustNotesForArc: a pattern event's rational arc becomes an integer frame on the bank's own clock, and its atom becomes a frequency in Hz. Everything after that is integers, which is why the timing can be asserted in samples.

Pattern → notes

const val FAUST_PATTERN_SUSTAIN: Double = 0.9

The default fraction of its arc a note holds its gate for.

Not 1.0, and this is the trap worth naming. At sustain = 1.0 the gate never falls between adjacent notes, so a repeated pitch is one continuous tone with a single attack — and an onset detector pointed at that signal finds envelope ripple instead of notes. (Exactly that mistake was made in this package on 2026-08-12: 22 "onsets" found in a render of 10 notes.) At 0.9 the gate falls for a tenth of every arc, every note re-attacks, and what you measure is what was notated. Ask for 1.0 explicitly if you want a legato pad, and then do not measure onsets.

fun faustNotesForArc

Turn one query of pattern into notes on a frame clock.

Pitch, in the order it is looked for:

  1. the note parameter, as a MIDI number — pat("x*4") with note("0 3 7 12");
  2. the atom itself through patNoteNumber, which reads 60, c, ef3, cs4 — Tidal's octave numbering, so c5 is middle C;
  3. nothing: the event is skipped and counted, rather than played at some arbitrary default. A drum name has no pitch, and inventing one would make pat("bd sd") sound like a broken instrument instead of like the wrong pattern for this voice.

gain and sustain are read off the event's parameters where present, so the whole with gain(sine()) surface works unchanged.

@param frameOrigin the frame that cycle 0 begins on, so a live feeder can keep calling this per cycle @return the notes, and how many events had no pitch — see FaustPatternNotes

body — 43 lines, from line 74
fun faustNotesForArc(
    pattern: PatPattern<String>,
    arc: PatArc,
    cps: Double,
    sampleRate: Int,
    sustain: Double = FAUST_PATTERN_SUSTAIN,
    gain: Double = 1.0,
    frameOrigin: Long = 0L
): FaustPatternNotes {
    require(cps > 0.0) { "cps must be > 0" }
    val secondsPerCycle = 1.0 / cps
    val notes = mutableListOf<FaustNote>()
    var unpitched = 0

    val events = pattern.events(arc)
        // A note whose onset is in an earlier query belongs to that query; striking it again would double
        // it. This is the whole/part distinction PatEvent.hasOnset exists for.
        .filter { it.hasOnset }
        .sortedWith(compareBy({ it.arc.start }, { it.arc.end }))

    for (event in events) {
        val extent = event.extent
        val midi = event.annotations["note"]?.trim()?.toDoubleOrNull()
            ?: patNoteNumber(event.value)?.toDouble()
        if (midi == null) {
            unpitched++
            continue
        }
        val startFrame = frameOrigin + (extent.start.toDouble() * secondsPerCycle * sampleRate).roundToLong()
        val arcFrames = (extent.dur.toDouble() * secondsPerCycle * sampleRate).roundToLong()
        val held = event.annotations["sustain"]?.trim()?.toDoubleOrNull() ?: sustain
        val lengthFrames = (arcFrames * held.coerceIn(0.0, 1.0)).roundToLong().coerceAtLeast(1L)
        val eventGain = gain * (event.annotations["gain"]?.trim()?.toDoubleOrNull() ?: 1.0)
        notes += FaustNote(
            startFrame = startFrame,
            endFrame = startFrame + lengthFrames,
            hz = patMidiToHz(midi),
            gain = eventGain,
            label = event.value
        )
    }
    return FaustPatternNotes(notes, unpitched)
}

data class FaustPatternNotes(val notes: List<FaustNote>, val unpitched: Int)

The notes a pattern query produced, and how many of its events had no pitch to play.

val size: Int get() = notes.size

override fun toString(): String

    override fun toString(): String =
        "FaustPatternNotes(${notes.size} notes" + (if (unpitched == 0) "" else ", $unpitched unpitched") + ")"
}

fun faustPatternNotes

Every note in cycles whole cycles of pattern, from frame 0.

fun faustPatternNotes(
    pattern: PatPattern<String>,
    cycles: Int,
    cps: Double = 1.0,
    sampleRate: Int = 48_000,
    sustain: Double = FAUST_PATTERN_SUSTAIN,
    gain: Double = 1.0
): FaustPatternNotes {
    require(cycles > 0) { "cycles must be > 0" }
    return faustNotesForArc(
        pattern, PatArc(PatQ.ZERO, PatQ.of(cycles)), cps, sampleRate, sustain, gain
    )
}

fun faustParamAutomation

Turn a pattern of numbers into shared-parameter changes for control.

The other half of "a pattern plays it", and the reason it is worth having: a Pat pattern is not only a rhythm of notes, it is a rhythm of anything. pat("300 4000 800 6000").nums() is a cutoff that steps on the sixteenth, and because it goes through the same event timeline as the notes it steps on the sample of the sixteenth rather than somewhere inside the block containing it.

Continuous patterns work too — sine().range(300.0, 6000.0).segment(16) — because segment is what turns a signal into events, and events are what this reads. A truly continuous sweep would need a change per sample and is the program's own job (si.smoo), not the scheduler's.

@param values one value per event; each event's onset is when the value is written

fun faustParamAutomation(
    values: PatPattern<Double>,
    control: FaustControl,
    arc: PatArc,
    cps: Double,
    sampleRate: Int,
    frameOrigin: Long = 0L
): List<FaustParamChange> {
    require(cps > 0.0) { "cps must be > 0" }
    val secondsPerCycle = 1.0 / cps
    return values.events(arc)
        .filter { it.hasOnset }
        .sortedBy { it.extent.start }
        .map { event ->
            FaustParamChange(
                frame = frameOrigin +
                    (event.extent.start.toDouble() * secondsPerCycle * sampleRate).roundToLong(),
                control = control,
                value = event.value
            )
        }
}

fun faustParamAutomation

faustParamAutomation over cycles whole cycles from frame 0.

fun faustParamAutomation(
    values: PatPattern<Double>,
    control: FaustControl,
    cycles: Int,
    cps: Double = 1.0,
    sampleRate: Int = 48_000
): List<FaustParamChange> {
    require(cycles > 0) { "cycles must be > 0" }
    return faustParamAutomation(values, control, PatArc(PatQ.ZERO, PatQ.of(cycles)), cps, sampleRate)
}

Offline render — no audio device, no GPU, so it is the thing that can be asserted on

class FaustPatternRender

A rendered pattern, with the notes that were asked for and what the allocator did with them.

class FaustPatternRender(
    val left: FloatArray,
    val right: FloatArray,
    val sampleRate: Int,
    val notes: List<FaustNote>,
    val voices: Int,
    /** Events with no pitch, skipped rather than invented. */
    val unpitched: Int,
    val steals: Long,
    val tailCuts: Long,
    val peakConcurrency: Int,
    /** Shared-parameter changes that landed — see [faustParamAutomation]. */
    val automationApplied: Long = 0L
) {

fun wav(normalizePeak: Float? = null): ByteArray

Encode these samples as stereo 16-bit PCM WAV; normalize only when explicitly requested.

    fun wav(normalizePeak: Float? = null): ByteArray =
        afield.audio.engine.WavFloatArrayWriter.encodeStereo16(left, right, sampleRate, normalizePeak)

val frames: Int get() = left.size

val seconds: Double get() = frames.toDouble() / sampleRate

fun mono(): FloatArray = FloatArray(frames) { (left[it] + right[it]) * 0.5f }

The mono sum, which is what every measurement here is taken on.

val peak: Float

    val peak: Float
        get() {
            var p = 0f
            for (i in 0 until frames) p = max(p, max(abs(left[i]), abs(right[i])))
            return p
        }

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this,
        "frames" to frames,
        "seconds" to "%.3f".format(seconds),
        "notes" to notes.size,
        "voices" to voices,
        "steals" to steals,
        "peakConcurrency" to peakConcurrency
    )
}

fun faustPatternRender

Render cycles cycles of pattern through a polyphonic bank of program, offline.

Nothing here opens an audio device or a window, which is what makes this the reliable path for a figure in the documentation and the only path a test can assert on. The bank is built, rendered in blocks — the same FaustVoiceBank.render the audio thread calls, at the same block size, so what is measured is what would be heard — and closed.

@param gain written to the program's own gain control per note; null means the value the program declared as that slider's default, which is the level its author chose and the only level that is guaranteed to leave headroom for the other voices. A gain of 1.0 is full scale for one voice, so a three-note chord at 1.0 clips — which is not a bug in the mixer, it is three fortissimo notes. @param blockFrames render in blocks of this size, so the sub-block scheduling is genuinely exercised rather than bypassed by one enormous call @param tailSeconds keep rendering after the last note ends, so its release is in the file @param automation shared-parameter changes on the same timeline as the notes — see faustParamAutomation @param stealReleaseFrames see FaustVoiceBank.stealReleaseFrames — the one knob here with an audible consequence, and the only way to hear it is to render the same over-subscribed passage twice

body — 47 lines, from line 256
fun faustPatternRender(
    program: FaustProgram,
    pattern: PatPattern<String>,
    cycles: Int,
    cps: Double = 1.0,
    voices: Int = 8,
    sampleRate: Int = 48_000,
    sustain: Double = FAUST_PATTERN_SUSTAIN,
    gain: Double? = null,
    mixGain: Double = 1.0,
    blockFrames: Int = 128,
    tailSeconds: Double = 0.25,
    automation: List<FaustParamChange> = emptyList(),
    stealReleaseFrames: Long = FaustVoiceBank.RETRIGGER_FRAMES
): FaustPatternRender {
    val controls = FaustPolyControls.of(program)
    val noteGain = gain ?: controls.gain?.init ?: 1.0
    val queried = faustPatternNotes(pattern, cycles, cps, sampleRate, sustain, noteGain)
    val patternFrames = (cycles / cps * sampleRate).roundToLong()
    val lastNote = queried.notes.maxOfOrNull { it.endFrame } ?: 0L
    val frames = (max(patternFrames, lastNote) + (tailSeconds * sampleRate).roundToLong())
        .coerceIn(1L, sampleRate.toLong() * 600L).toInt()

    val left = FloatArray(frames)
    val right = FloatArray(frames)
    val bank = FaustVoiceBank(program, sampleRate, voices, mixGain, stealReleaseFrames)
    try {
        bank.schedule(queried.notes)
        bank.automate(automation)
        val bl = FloatArray(blockFrames)
        val br = FloatArray(blockFrames)
        var done = 0
        while (done < frames) {
            val n = min(blockFrames, frames - done)
            bank.render(bl, br, n)
            System.arraycopy(bl, 0, left, done, n)
            System.arraycopy(br, 0, right, done, n)
            done += n
        }
        return FaustPatternRender(
            left, right, sampleRate, queried.notes, voices, queried.unpitched,
            bank.steals, bank.tailCuts, bank.peakConcurrency, bank.automationApplied
        )
    } finally {
        bank.close()
    }
}

fun faustPatternRender

faustPatternRender from Faust source, compiling it first.

fun faustPatternRender(
    source: String,
    pattern: PatPattern<String>,
    cycles: Int,
    cps: Double = 1.0,
    voices: Int = 8,
    sampleRate: Int = 48_000,
    sustain: Double = FAUST_PATTERN_SUSTAIN,
    gain: Double? = null,
    name: String = "faustpoly",
    stealReleaseFrames: Long = FaustVoiceBank.RETRIGGER_FRAMES
): FaustPatternRender {
    val (body, _) = stripFaustHeader(source)
    return faustPatternRender(
        FaustEngine.compile(name, body), pattern, cycles, cps, voices, sampleRate, sustain, gain,
        stealReleaseFrames = stealReleaseFrames
    )
}

Measuring what came out — onsets, pitch, spectrum

fun faustEnvelope(x: FloatArray, sampleRate: Int, releaseMs: Double = 30.0): FloatArray

A peak-following envelope: instant attack, exponential release.

Instant attack because the thing being looked for is the attack; an attack-smoothed envelope would put the onset later than it happened, by an amount that depends on the smoothing rather than on the music.

fun faustEnvelope(x: FloatArray, sampleRate: Int, releaseMs: Double = 30.0): FloatArray {
    val coef = exp(-1.0 / (releaseMs / 1000.0 * sampleRate)).toFloat()
    val env = FloatArray(x.size)
    var e = 0f
    for (i in x.indices) {
        val a = abs(x[i])
        e = if (a > e) a else e * coef
        env[i] = e
    }
    return env
}

fun faustOnsets

The frame of every note attack in x.

A burst is a run of samples that is not quiet, allowing quiet stretches shorter than minGapMs inside it; the onset is the burst's first sample. That is the whole algorithm, and it is not a threshold crossing on a smoothed envelope, because the reported frame then depends on the smoothing rather than on the music — and it is not period-dependent, because a sawtooth's |x| passes through zero once a cycle and any envelope release long enough to bridge that is also long enough to bridge the gap between two notes.

That mistake was made here on the first run of synth.faust.poly: a 30ms envelope release needed 166ms of silence to fall below the close threshold, the gaps were 71ms, and the detector reported 1 onset for 14 notes. The gap-based version reports 14.

The signal has to have onsets for this to mean anything. Between two notes there must be real silence, which means the gate has to fall (see FAUST_PATTERN_SUSTAIN) and the program's release has to finish inside the gap. faustSilentGaps is the check for that, and synth.faust.poly runs it before it believes any onset count — because an onset detector pointed at a continuous tone will always find something.

@param openFraction a burst is only a note if it reaches this fraction of the signal's peak @param closeFraction below this fraction of the peak counts as quiet @param minGapMs quiet shorter than this is a waveform zero crossing, not a gap between notes

fun faustOnsets(
    x: FloatArray,
    sampleRate: Int,
    openFraction: Double = 0.02,
    closeFraction: Double = 0.002,
    minGapMs: Double = 3.0
): List<Int> {
    if (x.isEmpty()) return emptyList()
    var peak = 0f
    for (v in x) peak = max(peak, abs(v))
    if (peak <= 0f) return emptyList()

    val open = (peak * openFraction).toFloat()
    val close = (peak * closeFraction).toFloat()
    val minGap = (minGapMs / 1000.0 * sampleRate).toInt().coerceAtLeast(1)

    val onsets = mutableListOf<Int>()
    var burstStart = -1
    var burstPeak = 0f
    // Start as though a long silence had just ended, so a signal that begins mid-attack still has an onset.
    var quietRun = minGap
    for (i in x.indices) {
        val a = abs(x[i])
        if (a <= close) {
            if (quietRun < minGap) quietRun++
            if (burstStart >= 0 && quietRun >= minGap) {
                if (burstPeak > open) onsets += burstStart
                burstStart = -1
                burstPeak = 0f
            }
        } else {
            if (burstStart < 0 && quietRun >= minGap) burstStart = i
            if (burstStart >= 0 && a > burstPeak) burstPeak = a
            quietRun = 0
        }
    }
    if (burstStart >= 0 && burstPeak > open) onsets += burstStart
    return onsets
}

fun faustSilentGaps

Maximal runs of near-silence in x, as first..last frame ranges.

The proof that a render has attacks at all. A pattern rendered at sustain = 1.0 has no gaps, and every onset an onset detector reports on it is a fiction.

fun faustSilentGaps(
    x: FloatArray,
    sampleRate: Int,
    threshold: Double = 1e-4,
    minMs: Double = 3.0
): List<IntRange> {
    val minFrames = (minMs / 1000.0 * sampleRate).toInt().coerceAtLeast(1)
    val gaps = mutableListOf<IntRange>()
    var start = -1
    val t = threshold.toFloat()
    for (i in x.indices) {
        val quiet = abs(x[i]) <= t
        if (quiet) {
            if (start < 0) start = i
        } else if (start >= 0) {
            if (i - start >= minFrames) gaps += start until i
            start = -1
        }
    }
    if (start >= 0 && x.size - start >= minFrames) gaps += start until x.size
    return gaps
}

fun faustFundamentalHz

The fundamental of x[from until from+count], in Hz, by normalised autocorrelation.

The smallest lag whose correlation is within octaveTolerance of the best is taken, not the best lag itself: a sawtooth correlates almost perfectly with itself at twice its period too, and the naive answer is an octave down about half the time.

@return the fundamental, or NaN when the window is too short or silent

body — 56 lines, from line 448
fun faustFundamentalHz(
    x: FloatArray,
    from: Int,
    count: Int,
    sampleRate: Int,
    minHz: Double = 40.0,
    maxHz: Double = 3000.0,
    octaveTolerance: Double = 0.92
): Double {
    val minLag = (sampleRate / maxHz).toInt().coerceAtLeast(2)
    val maxLag = (sampleRate / minHz).toInt()
    val window = count - maxLag
    if (from < 0 || from + count > x.size || window < minLag * 4) return Double.NaN

    val r = DoubleArray(maxLag + 1)
    var best = 0.0
    for (lag in minLag..maxLag) {
        var num = 0.0
        var e0 = 0.0
        var e1 = 0.0
        for (i in 0 until window) {
            val a = x[from + i].toDouble()
            val b = x[from + i + lag].toDouble()
            num += a * b
            e0 += a * a
            e1 += b * b
        }
        r[lag] = if (e0 > 0.0 && e1 > 0.0) num / sqrt(e0 * e1) else 0.0
        if (r[lag] > best) best = r[lag]
    }
    if (best <= 0.1) return Double.NaN

    var chosen = -1
    for (lag in minLag..maxLag) {
        if (r[lag] >= best * octaveTolerance &&
            (lag == minLag || r[lag] >= r[lag - 1]) &&
            (lag == maxLag || r[lag] >= r[lag + 1])
        ) {
            chosen = lag
            break
        }
    }
    if (chosen < 0) return Double.NaN

    // Parabolic interpolation between the three correlations around the peak: a lag is an integer, a
    // period is not, and at 261.63Hz one sample of lag is 1.4Hz.
    var refined = chosen.toDouble()
    if (chosen > minLag && chosen < maxLag) {
        val a = r[chosen - 1]
        val b = r[chosen]
        val c = r[chosen + 1]
        val denom = 2.0 * (2.0 * b - a - c)
        if (abs(denom) > 1e-12) refined += (a - c) / denom
    }
    return sampleRate / refined
}

fun faustNormalisedStep(x: FloatArray, from: Int, count: Int): Double

The largest sample-to-sample step in x[from until from+count], as a fraction of the window's own peak.

Scale-free, which is the whole point: a click is a step in the waveform, and comparing raw deltas between two windows at different pitches or different envelope levels compares the wrong thing — that mistake produced a confident 1.90x that meant nothing. A value near 1.0 means the largest single-sample jump is as big as the whole local peak: not a slope, a discontinuity.

Use it as a ratio against the same measurement taken a few thousand samples later, once the note has settled. See FaustVoiceBank.stealReleaseFrames, which is the knob it exists to measure.

fun faustNormalisedStep(x: FloatArray, from: Int, count: Int): Double {
    var step = 0f
    var peak = 0f
    for (i in max(1, from) until min(x.size, from + count)) {
        step = max(step, abs(x[i] - x[i - 1]))
        peak = max(peak, abs(x[i]))
    }
    return if (peak <= 0f) 0.0 else step.toDouble() / peak.toDouble()
}

data class FaustPeak(val hz: Double, val magnitude: Double)

One spectral peak: where it is and how strong.

override fun toString(): String = "${"%.1f".format(hz)}Hz@${"%.4f".format(magnitude)}"

fun faustSpectrumPeaks

The spectral peaks of x[from…], Hann-windowed, in the band [minHz, maxHz].

This is the measurement that can tell polyphony from monophony, and nothing else here can: three notes struck together put three fundamentals in one spectrum, and a single voice cannot do that however it is driven. Restricting the band below the second harmonic of the lowest note keeps the answer unambiguous — every peak in the band is somebody's fundamental.

@param size FFT size, rounded down to a power of two @param relThreshold keep peaks at least this fraction of the strongest peak in the band

body — 50 lines, from line 542
fun faustSpectrumPeaks(
    x: FloatArray,
    from: Int,
    size: Int,
    sampleRate: Int,
    minHz: Double = 20.0,
    maxHz: Double = 20_000.0,
    relThreshold: Double = 0.1
): List<FaustPeak> {
    var n = Integer.highestOneBit(size)
    if (from + n > x.size) n = Integer.highestOneBit(x.size - from)
    if (n < 64) return emptyList()

    val data = FloatArray(2 * n)
    for (i in 0 until n) {
        val w = 0.5 - 0.5 * cos(2.0 * PI * i / (n - 1))
        data[2 * i] = (x[from + i] * w).toFloat()
        data[2 * i + 1] = 0f
    }
    FFT(n).forward(data)

    val binHz = sampleRate.toDouble() / n
    val mag = DoubleArray(n / 2) { k ->
        val re = data[2 * k].toDouble()
        val im = data[2 * k + 1].toDouble()
        sqrt(re * re + im * im) / n
    }

    val loBin = (minHz / binHz).toInt().coerceAtLeast(1)
    val hiBin = (maxHz / binHz).toInt().coerceAtMost(n / 2 - 2)
    if (hiBin <= loBin) return emptyList()

    var strongest = 0.0
    for (k in loBin..hiBin) strongest = max(strongest, mag[k])
    if (strongest <= 0.0) return emptyList()

    val peaks = mutableListOf<FaustPeak>()
    for (k in loBin..hiBin) {
        if (mag[k] < strongest * relThreshold) continue
        if (mag[k] < mag[k - 1] || mag[k] < mag[k + 1]) continue
        // Parabolic interpolation on the magnitudes, so a peak is located to a fraction of a bin.
        val a = mag[k - 1]
        val b = mag[k]
        val c = mag[k + 1]
        val denom = 2.0 * (2.0 * b - a - c)
        val delta = if (abs(denom) > 1e-18) (a - c) / denom else 0.0
        peaks += FaustPeak((k + delta) * binHz, b)
    }
    return peaks.sortedByDescending { it.magnitude }
}

Live: a box that is a polyphonic instrument, and a box that is a pattern playing one

class FaustPolyLaunch internal constructor

A polyphonic Faust program, and the handle a box holds it by.

The same arrangement as FaustLaunch and for the same reason — not a StereoRenderable, so that the generic audio return-value handler cannot claim it and leak the GraalVM context — with one addition: it may own a feeder thread turning a pattern into notes, and that has to stop when the box does.

class FaustPolyLaunch internal constructor(
    val program: FaustProgram,
    val bank: FaustVoiceBank,
    internal val node: FaustPolyNode,
    val gain: Float
) : Closeable {

    private val closed = AtomicBoolean(false)
    private val feeders = mutableListOf<FaustPatternFeeder>()

val sharedControls: List<FaustControl>

The controls a person can move: everything the program declares except freq, gate and gain, which the notes drive. A cutoff slider on a polyphonic instrument moves all eight voices at once.

    val sharedControls: List<FaustControl>
        get() {
            val perNote = setOfNotNull(
                bank.controls.freq.index, bank.controls.gate.index, bank.controls.gain?.index
            )
            return program.ui.writable.filter { it.index !in perNote }
        }

operator fun set(label: String, value: Double)

Set a shared control by label on every voice.

    operator fun set(label: String, value: Double) {
        bank[label] = value
    }

fun play(hz: Double, seconds: Double = 0.25, gain: Double = 1.0, afterSeconds: Double = 0.0)

Play one note now, or afterSeconds from now.

    fun play(hz: Double, seconds: Double = 0.25, gain: Double = 1.0, afterSeconds: Double = 0.0) =
        bank.play(hz, seconds, gain, afterSeconds)

fun note(midi: Number, seconds: Double = 0.25, gain: Double = 1.0, afterSeconds: Double = 0.0)

Play a MIDI note number — launch.note(60) is middle C.

    fun note(midi: Number, seconds: Double = 0.25, gain: Double = 1.0, afterSeconds: Double = 0.0) =
        bank.play(patMidiToHz(midi.toDouble()), seconds, gain, afterSeconds)

fun chord(midis: List<Number>, seconds: Double = 0.5, gain: Double = 1.0)

Play a chord, every note struck on the same frame — the thing a monophonic path cannot do.

    fun chord(midis: List<Number>, seconds: Double = 0.5, gain: Double = 1.0) {
        val start = bank.frame
        val length = (seconds * bank.sampleRate).roundToLong().coerceAtLeast(1L)
        bank.schedule(
            midis.map {
                FaustNote(start, start + length, patMidiToHz(it.toDouble()), gain, it.toString())
            }
        )
    }

fun play

Keep feeding this bank from pattern, one cycle at a time, staying aheadSeconds ahead of the audio.

The feeder runs on its own daemon thread and the frames are the clock, so being early costs nothing and is not a source of jitter: a note's frame is computed once, from rational cycle time, and the audio thread starts it on that exact sample however early it arrived.

@param automate shared controls patterned by label alongside the notes — automate = mapOf("cutoff" to pat("400 3000 900 6000").nums()). An unknown label is a printed warning rather than fatal, because live-coding a program is how controls get renamed and a typo should not silence the instrument.

    fun play(
        pattern: PatPattern<String>,
        cps: Double = 1.0,
        sustain: Double = FAUST_PATTERN_SUSTAIN,
        gain: Double? = null,
        aheadSeconds: Double = 0.4,
        automate: Map<String, PatPattern<Double>> = emptyMap()
    ): FaustPatternFeeder {
        val resolved = automate.mapNotNull { (label, values) ->
            val control = program.ui.writable.firstOrNull {
                it.label == label || it.qualifiedLabel == label || it.address == label
            }
            if (control == null) {
                println("Faust: '${program.name}' declares no control '$label' — not automating it")
                null
            } else {
                control to values
            }
        }.toMap()
        val feeder = FaustPatternFeeder(
            bank, pattern, cps, sustain, gain ?: bank.controls.gain?.init ?: 1.0, aheadSeconds, resolved
        )
        synchronized(feeders) { feeders += feeder }
        feeder.start()
        return feeder
    }

override fun close()

    override fun close() {
        if (!closed.compareAndSet(false, true)) return
        synchronized(feeders) { feeders.forEach { it.stop() } }
        bank.close()
    }

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this,
        "program" to program.name,
        "voices" to bank.voices,
        "shared" to sharedControls.map { it.label },
        "notesPlayed" to bank.notesPlayed,
        "gain" to gain
    )
}

class FaustPatternFeeder internal constructor

Turns a pattern into notes for a FaustVoiceBank, one cycle at a time, ahead of the audio thread.

The pattern query is not on the audio thread — a PatPattern query allocates, and a cycle of a big composed pattern is not a 2.67ms job — but that costs no accuracy, because what it produces is frames. The audio thread's only remaining job is to start a note on the frame it was given, which is integer comparison. Being 400ms early and being exactly on time are the same sound.

class FaustPatternFeeder internal constructor(
    private val bank: FaustVoiceBank,
    val pattern: PatPattern<String>,
    val cps: Double,
    val sustain: Double,
    val gain: Double,
    val aheadSeconds: Double,
    /** Shared controls patterned alongside the notes. */
    val automated: Map<FaustControl, PatPattern<Double>> = emptyMap()
) {
    private val running = AtomicBoolean(false)
    private var thread: Thread? = null

var cyclesFed: Long

Cycles fed so far.

    @Volatile
    var cyclesFed: Long = 0L
        private set

var unpitched: Long

Events the pattern produced that carried no pitch, and so were skipped.

    @Volatile
    var unpitched: Long = 0L
        private set

internal fun start()

    internal fun start() {
        if (!running.compareAndSet(false, true)) return
        thread = kotlin.concurrent.thread(isDaemon = true, name = "faust-pattern-feeder") {
            val origin = bank.frame
            val framesPerCycle = bank.sampleRate / cps
            var cycle = 0L
            val ahead = (aheadSeconds * bank.sampleRate)
            while (running.get()) {
                val horizon = bank.frame + ahead
                var fed = 0
                while (running.get() && origin + cycle * framesPerCycle <= horizon && fed < 64) {
                    val arc = PatArc(PatQ.of(cycle), PatQ.of(cycle + 1))
                    val queried = faustNotesForArc(
                        pattern, arc, cps, bank.sampleRate, sustain, gain, origin
                    )
                    bank.schedule(queried.notes)
                    for ((control, values) in automated) {
                        bank.automate(
                            faustParamAutomation(values, control, arc, cps, bank.sampleRate, origin)
                        )
                    }
                    if (queried.unpitched > 0) unpitched += queried.unpitched
                    cycle++
                    cyclesFed = cycle
                    fed++
                }
                runCatching { Thread.sleep(20L) }.onFailure { return@thread }
            }
        }
    }

fun stop()

Stop feeding. Notes already scheduled still play; FaustVoiceBank.allNotesOff silences those.

    fun stop() {
        if (!running.compareAndSet(true, false)) return
        thread?.interrupt()
        thread = null
    }

val isRunning: Boolean get() = running.get()

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this, "cps" to cps, "cyclesFed" to cyclesFed, "running" to isRunning
    )
}

fun Box.faustPoly

A box that is a polyphonic instrument.

val inst = faustPoly("""
#faust
import("stdfaust.lib");
freq   = hslider("freq [unit:Hz]", 220, 40, 4000, 0.001);
gain   = hslider("gain", 0.3, 0, 1, 0.001);
gate   = button("gate");
cutoff = hslider("cutoff [unit:Hz]", 3000, 60, 12000, 1);
env = en.adsr(0.004, 0.09, 0.55, 0.06, gate);
process = os.sawtooth(freq) : fi.lowpass(3, cutoff) * env * gain <: _,_;
""", voices = 8)

inst.play(pat("c e g <b a>").fast(2), cps = 1.0)
inst

Returning the launch as the box's last expression ties the sound to the box exactly as faust does: the box stays lit while it plays, and stopping it stops the sound, the feeder threads and the DSP contexts.

The program must declare freq and gate — Faust's own polyphonic convention — or this throws with a message naming what it did declare. See FaustPolyControls.

@param voices how many instances of the program to run; the cost per block is roughly linear in this and synth.faust.poly prints the curve @param controls generate a control group for the shared controls (everything except freq/gate/gain) @param stealReleaseFrames how long a stolen voice holds its gate down before the incoming note attacks. The default starts the new note on time and clicks when the bank runs out of voices, by a measured amount; (0.03 * 48000).toLong() trades that click for a stolen note 30ms late. This is the one knob here with an audible consequence, so it is reachable from the one-liner rather than only from FaustVoiceBank — see its KDoc for both measurements.

fun Box.faustPoly(
    source: String,
    voices: Int = 8,
    gain: Float = 1f,
    mixGain: Double = 1.0,
    controls: Boolean = true,
    channel: String = "controls",
    stealReleaseFrames: Long = FaustVoiceBank.RETRIGGER_FRAMES,
    lineOffset: Int = -1
): FaustPolyLaunch {
    val (body, headerLines) = stripFaustHeader(source)
    val offset = if (lineOffset >= 0) lineOffset else inferFaustLineOffset(this, source)

    val program = try {
        FaustEngine.compile(faustPolyModuleName(this), body)
    } catch (e: FaustCompileError) {
        val boxLine = offset + headerLines + ((e.line ?: 1) - 1)
        runCatching { displayError(e.faustMessage, "code", boxLine) }
        runCatching { println("Faust: ${e.raw.trim()}") }
        throw e
    }

    val engine = FieldAudioEngine.shared()
    val bank = FaustVoiceBank(
        program, engine.sampleRate, voices, mixGain, stealReleaseFrames = stealReleaseFrames
    )
    val launch = FaustPolyLaunch(program, bank, FaustPolyNode(bank), gain)

    if (controls && launch.sharedControls.isNotEmpty()) {
        faustPolyControls(launch, channel)
    }
    return launch
}

fun Box.faustPolyControls(launch: FaustPolyLaunch, channel: String = "controls")

Build the control group for a polyphonic launch: the shared controls only.

freq, gate and gain are absent. They are per-note, written by the allocator on the sample a note begins, and a slider that fought the notes for them would look broken. Everything else — a cutoff, a resonance, a detune — is shared across the whole bank, which is what a synthesiser's front panel is.

fun Box.faustPolyControls(launch: FaustPolyLaunch, channel: String = "controls") {
    controlGroup(channel) {
        for (control in launch.sharedControls) {
            when {
                control.isToggle || control.isMomentary -> checkbox(control.displayLabel, control.init != 0.0) { on ->
                    launch.bank.setAll(control, if (on) 1.0 else 0.0)
                }

                else -> slider(
                    label = control.displayLabel,
                    min = control.min,
                    max = control.max,
                    step = if (control.step > 0.0) control.step else (control.max - control.min) / 200.0,
                    initialValue = control.init
                ) { value ->
                    launch.bank.setAll(control, value)
                }
            }
        }
    }
}

fun Box.showFaustPattern

Render a pattern through a polyphonic Faust program and put it in this box's output — a player, a waveform, and the note count.

The one-liner that makes the whole thing visible without an audio device, and the way to put a polyphonic Faust figure in the documentation.

@param channel the waveform's output channel. The player lands on "$channel-audio", because one channel cannot hold both: display(channel, …) replaces a channel rather than appending to it. @param automate shared controls patterned by label — mapOf("cutoff" to pat("400 3000 900 6000").nums()) @param stealReleaseFrames as faustPoly: the default starts a stolen note on time and clicks, and (0.03 * 48000).toLong() trades the click for lateness. Reachable here because this is the figure a reader is looking at when they want to hear the difference — render it twice and compare. @return the render, so a box can measure it too — faustOnsets, faustFundamentalHz, faustSpectrumPeaks

body — 67 lines, from line 895
fun Box.showFaustPattern(
    source: String,
    pattern: PatPattern<String>,
    cycles: Int = 2,
    cps: Double = 1.0,
    voices: Int = 8,
    sustain: Double = FAUST_PATTERN_SUSTAIN,
    gain: Double? = null,
    automate: Map<String, PatPattern<Double>> = emptyMap(),
    stealReleaseFrames: Long = FaustVoiceBank.RETRIGGER_FRAMES,
    channel: String = "main",
    maxPoints: Int = 2400
): FaustPatternRender {
    val (body, headerLines) = stripFaustHeader(source)
    val offset = inferFaustLineOffset(this, source)
    val program = try {
        FaustEngine.compile(faustPolyModuleName(this), body)
    } catch (e: FaustCompileError) {
        runCatching { displayError(e.faustMessage, "code", offset + headerLines + ((e.line ?: 1) - 1)) }
        throw e
    }

    val automation = automate.flatMap { (label, values) ->
        val control = program.ui.writable.firstOrNull {
            it.label == label || it.qualifiedLabel == label || it.address == label
        }
        if (control == null) {
            println("Faust: '${program.name}' declares no control '$label' — not automating it")
            emptyList()
        } else {
            faustParamAutomation(values, control, cycles, cps, 48_000)
        }
    }

    val render = faustPatternRender(
        program, pattern, cycles = cycles, cps = cps, voices = voices, sampleRate = 48_000,
        sustain = sustain, gain = gain, automation = automation,
        stealReleaseFrames = stealReleaseFrames
    )
    // The player gets its OWN channel, because `display(channel, …)` replaces a channel rather than
    // appending to it: with both on [channel] the waveform below landed second and erased the player,
    // so the "audio player and a waveform" this function advertises was only ever the waveform. The
    // waveform keeps [channel] so that every existing figure stays where its author put it.
    val audioChannel = "$channel-audio"
    displayOpen(audioChannel)
    display(
        audioChannel,
        afield.audio.engine.WavFloatArrayWriter.encodeStereo16(
            render.left, render.right, render.sampleRate, normalizePeak = 0.9f
        )
    )

    val stride = (render.frames / maxPoints).coerceAtLeast(1)
    val indices = 0 until render.frames step stride
    showSignals(
        "left" to indices.map { render.left[it] },
        "right" to indices.map { render.right[it] },
        x = indices.map { it.toDouble() / render.sampleRate },
        channel = channel,
        title = "${program.name} — ${render.notes.size} notes, ${render.voices} voices, " +
            "peak concurrency ${render.peakConcurrency}" +
            (if (render.automationApplied == 0L) "" else ", ${render.automationApplied} param changes"),
        xTitle = "seconds",
        yTitle = "amplitude"
    )
    return render
}

Faust uses the module name as an identifier and as its diagnostic prefix; keep it legal and stable.

private fun faustPolyModuleName(box: Box): String {
    val id = box.id.filter { it.isLetterOrDigit() || it == '_' }
    return if (id.isEmpty()) "faustpolybox" else "poly_${id.take(40)}"
}

fun registerFaustPolyLaunchReturnValue()

Teach Field that a FaustPolyLaunch is something a box can be running.

registerFaustLaunchReturnValue's shape exactly, and it exists separately for the same reason: closing the audio patch is not enough, because the bank owns a GraalVM context and possibly a feeder thread.

@RunsAt(returnValueRegistration, priority = 10)
fun registerFaustPolyLaunchReturnValue() {
    ReturnValueRegistry.handlers += handler@{ inside, returnValue, strand, generation, launchEpoch, notify ->
        val launch = returnValue as? FaustPolyLaunch ?: return@handler false

        val handle = FieldAudioEngine.shared().register(audio(launch.node, gain = launch.gain))

        inMain {
            val job = kotlin.coroutines.coroutineContext[Job]!!
            if (!inside.registerLaunchJob(job, launchEpoch)) {
                handle.close()
                launch.close()
                notify(false, strand, generation)
                return@inMain
            }
            notify(true, strand, generation)
            try {
                while (inside.runGeneration[strand] == generation) {
                    yield()
                }
            } finally {
                handle.close()
                launch.close()
                inside.jobs.remove(job)
                notify(false, strand, generation)
            }
        }
        true
    }
}