FaustPoly.kt
src/jvmMain/kotlin/afield/audio/synth/FaustPoly.kt · 1025 lines
@file:afield.runtime.completion.CompletionCategory("Synthesis")
package afield.audio.synth
import org.graalvm.polyglot.Context
import org.graalvm.polyglot.HostAccess
import org.graalvm.polyglot.PolyglotAccess
import org.graalvm.polyglot.Source
import org.graalvm.polyglot.Value
import java.io.Closeable
import java.util.PriorityQueue
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToLong
A Faust program played polyphonically, by a pattern.
FaustDsp is one instance of one program: a drone with knobs. This is the other thing a Faust program
can be, an instrument: N copies of the same compiled DSP allocated to notes, with the notes coming
from Field's own pattern language. Nothing in Faust is recompiled to make that happen. A Faust factory
built with internal memory keeps its whole state at offset 0 of its own WebAssembly memory, so N
WebAssembly.Instances of one module are N independent voices for free.
One context, N instances
Each FaustDsp builds its own GraalVM context, and what is left of a block after compute is dominated
by the per-block Context.enter (see FaustDsp for the figures). N voices as N FaustDsps would pay
that entry N times a block, which would make the cost of polyphony a boundary cost rather than a DSP
cost. FaustVoiceBank hosts every voice in one context instead, one enter a block whatever N is.
That needs a host shim of its own: FaustDsp's keeps its instance in module-level JS globals and can
hold only one.
Both arrangements are timed against each other by synth.faust.poly, which prints the µs-per-block
curve for each and asserts that the one shipped here renders a block inside the block's own budget at
1, 2, 4, 8 and 16 voices.
The notes land on the sample, not on the block
A 128-frame block at 48kHz is 2.67ms, and quantising a note onset to that boundary is audible. So
render does not write parameters once per block. It enters the context once, then walks the block in
chunks that end where the next event begins: apply the writes due at this exact frame, compute up
to the next event, repeat. A note therefore begins on the sample it was scheduled for, and
synth.faust.poly asserts that in samples rather than in milliseconds.
This is also why FaustDsp.setParam is not used: that queue is drained once per render call, which is
block granularity by construction.
The controls are the program's own, and it says so when they are missing
Faust's polyphonic convention is freq / gate / gain, the same three names its -nvoices
architecture looks for, and they are discovered out of the program's declared UI by label rather than by
index. gain is optional; a program missing freq or gate is refused by FaustPolyControls, with a
message naming what is missing, what the program did declare, and the line of Faust that would fix it.
Writing to control index 0 instead would produce silence, which carries no diagnosis at all.
class FaustVoiceBank internal constructor
N instances of one compiled Faust program, allocated to notes and summed.
Notes are scheduled by frame and take a free voice where there is one, the oldest tail where there is
not, and the oldest sounding note after that. Every voice renders through one GraalVM context, which
close releases.
class FaustVoiceBank internal constructor(
val program: FaustProgram,
val sampleRate: Int,
val voices: Int,
/** Discovered `freq`/`gate`/`gain`. */
val controls: FaustPolyControls,
/** Every voice's output is summed and multiplied by this on the way out. */
val mixGain: Double = 1.0,
/**
* Below this, for a whole chunk and with its gate down, a voice counts as finished and can be
* reallocated without cutting a tail.
*
* This is the Faust architecture layer's own answer to "is this voice free" — watch the output — rather
* than a guess about the length of an envelope the host cannot see. Faust does not report its release
* time, so guessing it would be wrong for every program but the one it was tuned on.
*/
val idleThreshold: Float = 1e-5f,
/**
* How long a stolen voice holds its gate **down** before raising it for the incoming note, in samples.
*
* The one place in this class where there is a real trade and no right answer, so it is a knob with both
* ends measured by `synth.faust.poly`:
*
* * **1 sample (the default).** The stolen note starts essentially on time. It also clicks, and by a
* measured amount: Faust's `en.adsr` *resets to zero* on a trigger rather than continuing from the
* level it was at, so the outgoing note's amplitude appears as a one-sample step. Measured, the
* largest sample-to-sample delta across a steal is **0.99 of the local peak** against 0.36 in the
* same note once settled — 2.7×. That is audible, and it is what nearly every hardware polysynth
* does on voice overload.
* * **longer than the program's release** — `(0.03 * sampleRate)` for a 30ms release. The envelope
* reaches zero before the new note begins, so there is no step at all: measured **0.04×**, which is
* to say the largest jump across the steal is smaller than the sawtooth's own step in the same
* passage. The cost is that the stolen note is that late.
*
* A steal is an overload condition either way; the default prefers the note being on time. Set it to the
* program's release length if you would rather never hear it.
*/
val stealReleaseFrames: Long = RETRIGGER_FRAMES
) : Closeable {
companion object
companion object {
const val MAX_FRAMES: Int = 2048
Largest compute this bank will ask for in one call, per voice.
Smaller than FaustDsp.MAX_FRAMES because the scratch is reserved per voice inside that voice's own WebAssembly memory, and because chunks here are usually short anyway — they end at the next note.
const val MAX_VOICES: Int = 128
Voices beyond this are refused: it is a hundredfold past anything measured.
const val RETRIGGER_FRAMES: Long = 1L
Default stealReleaseFrames: one sample.
Enough for a Faust envelope to see a rising edge, since 1, 0, 1 over three samples is one — and
the smallest value that makes a stolen note actually attack. It is not enough to avoid the click;
see stealReleaseFrames for what is, and what that costs.
const val IDLE_DECISION_FRAMES: Int = 64
Shortest chunk allowed to declare a voice finished.
Chunks end at the next scheduled event, so they can be a single sample when two notes land on
adjacent frames. One sample of a releasing oscillator says nothing about whether it has stopped:
a zero-crossing reads as silence. Below this length the idle test may only keep a voice alive,
never end it — see the comment in mixChunk. 64 frames is 1.3ms at 48kHz, short enough that a
genuinely finished voice is reclaimed within a block and long enough to contain a cycle of
anything above ~750Hz.
constructor
Build a bank for program. Throws with a readable message if the program is not playable
polyphonically — see FaustPolyControls.of.
constructor(
program: FaustProgram,
sampleRate: Int,
voices: Int = 8,
mixGain: Double = 1.0,
stealReleaseFrames: Long = RETRIGGER_FRAMES
) : this(
program, sampleRate, voices, FaustPolyControls.of(program), mixGain,
stealReleaseFrames = stealReleaseFrames
)
init
Argument validation belongs HERE, above the context property, not in the init block below it.
Kotlin runs property initialisers and init blocks in source order, so a require placed after
context throws with a live GraalVM context already built and nothing holding a reference to close
it — FaustVoiceBank(p, sr, voices = 500) leaked one per attempt. Cheap to get wrong, invisible
until something calls this in a loop.
init {
require(voices in 1..MAX_VOICES) { "FaustVoiceBank: voices must be in 1..$MAX_VOICES, got $voices" }
require(sampleRate > 0) { "FaustVoiceBank: sampleRate must be positive, got $sampleRate" }
require(stealReleaseFrames >= 0L) {
"FaustVoiceBank: stealReleaseFrames must not be negative, got $stealReleaseFrames"
}
}
private val closed = AtomicBoolean(false)
private val context: Context = Context.newBuilder("js", "wasm")
.engine(FaustEngine.sharedEngine)
.allowExperimentalOptions(true)
.allowHostAccess(HostAccess.ALL)
.allowHostClassLookup { true }
.allowAllAccess(true)
.allowPolyglotAccess(PolyglotAccess.ALL)
.option("js.webassembly", "true")
.option("wasm.UseUnsafeMemory", "true")
.build()
val numInputs: Int
Channel counts as the compiled module reports them.
val numOutputs: Int
private val fnCompute: Value
private val fnSetParam: Value
private val fnGetParam: Value
One entry per voice: its memory as a host-readable buffer, and where its output scratch starts.
private val voiceBuffers: Array<Value>
private val voiceOutBase: LongArray
private val scratch = ByteArray(MAX_FRAMES * 4)
private val scratchFloats = java.nio.ByteBuffer.wrap(scratch)
.order(java.nio.ByteOrder.LITTLE_ENDIAN)
.asFloatBuffer()
private val voiceL = FloatArray(MAX_FRAMES)
private val voiceR = FloatArray(MAX_FRAMES)
the schedule
Why the handoff is two queues deep, and why the audio thread takes no lock
FaustDsp's KDoc states the rule this class inherits: no lock on the audio thread. An audio callback that can block behind another thread has a worst case measured in that other thread's scheduling, not in its own work, and the symptom is a dropout rather than a slow number.
The obvious shape — one PriorityQueue under a mutex, taken by both sides — breaks that rule twice
over. render would acquire it several times per chunk, and chunks are as short as the gap between
two notes; and a feeder calling schedule with a cycle's worth of notes would hold it across one heap
insert per note, with the audio thread waiting behind the whole batch.
So the ordered queues below are owned by the audio thread and never touched by anyone else, and
producers hand work over through lock-free ConcurrentLinkedQueues that render drains at the top of
each block. Producers pay the node allocation, on their own thread, where it is harmless. The audio
thread pays one drain per block and holds nothing.
The one visible consequence: work handed over during a render call is seen by the next block
rather than the current one — at 48kHz with 128-frame blocks, at most 2.67ms later. That is already
the modelled case, because notes are scheduled ahead of their frame and a note that arrives after its
own frame is played immediately and counted in lateNotes.
Notes not yet started, earliest first. Audio-thread-owned: only render may touch this.
private val pending = PriorityQueue<FaustNote>(64, compareBy({ it.startFrame }, { it.hz }))
Parameter changes not yet applied, earliest first. Audio-thread-owned, as pending.
private val pendingAutomation = PriorityQueue<FaustParamChange>(32, compareBy { it.frame })
Lock-free handoff, producer side. Drained into pending at the top of every render.
private val incomingNotes = java.util.concurrent.ConcurrentLinkedQueue<FaustNote>()
Lock-free handoff for automation. Drained into pendingAutomation at the top of every render.
private val incomingAutomation = java.util.concurrent.ConcurrentLinkedQueue<FaustParamChange>()
Set by clearSchedule from any thread; honoured by render at the top of the next block.
The audio thread owns pending, so a caller cannot empty it directly — it asks, and the request is
granted within one block.
private val clearRequested = AtomicBoolean(false)
pending``.size as of the end of the last render, published for pendingCount.
A plain read of a PriorityQueue from another thread while the audio thread mutates it is a data
race, so the audio thread publishes the size instead of exposing the collection.
@Volatile
private var pendingOwned: Int = 0
private val frameCounter = AtomicLong(0)
val frame: Long get() = frameCounter.get()
Absolute frames rendered so far — the bank's clock, and the only clock a note is timed against.
private class VoiceState {
var gateOn = false
var noteOnFrame = Long.MIN_VALUE
var endFrame = Long.MAX_VALUE
var hz = 0.0
/** True once the voice's own output has fallen below the idle threshold with its gate down. */
var silent = true
var note: FaustNote? = null
/** Frame at which a stolen voice raises its gate again; [Long.MAX_VALUE] when it is not stealing. */
var retriggerAt = Long.MAX_VALUE
var pendingHz = 0.0
var pendingGain = 0.0
var pendingEnd = Long.MAX_VALUE
}
private val state = Array(voices) { VoiceState() }
var steals: Long
How many gate-on voices were cut short because every voice was busy. Ideally zero.
@Volatile
var steals: Long = 0L
private set
var tailCuts: Long
How many released but still ringing voices were reused. Audible as a shortened tail, not a cut note.
@Volatile
var tailCuts: Long = 0L
private set
var lateNotes: Long
Notes that arrived after the frame they were meant to start on, and were played immediately.
@Volatile
var lateNotes: Long = 0L
private set
var notesPlayed: Long
Notes started.
@Volatile
var notesPlayed: Long = 0L
private set
var peakConcurrency: Int
The greatest number of voices sounding at once, so far. The measure of polyphony actually reached.
@Volatile
var peakConcurrency: Int = 0
private set
init
body — 65 lines, from line 303
init {
// `voices`/`sampleRate`/`stealReleaseFrames` are already validated above, before `context` exists.
context.enter()
try {
context.eval(source(FAUST_POLY_SHIM))
val bindings = context.getBindings("js")
bindings.putMember("__polyWasmBytes", program.wasm)
val setup = context.eval(
source(
"__faustPolySetup($voices, $sampleRate, ${program.ui.inputs}, " +
"${program.ui.outputs}, $MAX_FRAMES)"
)
)
val error = setup.getMember("error")
if (!error.isNull) {
error("Faust: could not instantiate ${voices} voices of '${program.name}': ${error.asString()}")
}
numInputs = setup.getMember("inputs").asInt()
numOutputs = setup.getMember("outputs").asInt()
if (numOutputs == 0) {
error("Faust: '${program.name}' has no audio outputs — `process` must produce at least one signal")
}
if (numInputs > 0) {
// An effect has nothing to be polyphonic about, and silently feeding it silence would look
// like a broken instrument rather than a misuse.
error(
"Faust: '${program.name}' declares $numInputs audio input(s), so it is an effect rather " +
"than an instrument — play it with faust(source, input = …) and put the polyphonic " +
"instrument on its input"
)
}
fnCompute = bindings.getMember("__faustPolyCompute")
fnSetParam = bindings.getMember("__faustPolySetParam")
fnGetParam = bindings.getMember("__faustPolyGetParam")
voiceOutBase = LongArray(voices)
voiceBuffers = Array(voices) { v ->
val offsets = context.eval(source("__faustPolyOffsets($v)"))
voiceOutBase[v] = offsets.getMember("outBase").asLong()
val buffer = context.eval(source("__faustPolyBuffer($v)"))
// The polyphonic path needs the bulk read; the per-sample JS fallback that FaustDsp keeps
// would cost N times as much and there is no reason to ship a version of this that is
// twenty times slower without saying so.
check(buffer.hasBufferElements()) {
"Faust: this GraalVM runtime does not expose WebAssembly memory as a host buffer, which " +
"the polyphonic path needs; a single voice still works through faust(...)"
}
buffer.readBuffer(voiceOutBase[v], scratch, 0, 4)
buffer
}
// Every voice starts silent: gate down, and the declared gain applied so a note-on only has to
// write freq and gate.
for (v in 0 until voices) {
fnSetParam.execute(v, controls.gate.index, 0f)
}
} catch (t: Throwable) {
runCatching { context.leave() }
runCatching { context.close(true) }
throw t
}
runCatching { context.leave() }
}
scheduling
fun schedule(notes: Collection<FaustNote>)
Schedule notes for their own frames on this bank's clock.
Safe from any thread and at any time: a note whose frame has already passed is played at the next opportunity and counted in lateNotes, so a feeder that falls behind loses time rather than notes.
fun schedule(notes: Collection<FaustNote>) {
if (notes.isEmpty() || closed.get()) return
incomingNotes.addAll(notes)
}
fun schedule(note: FaustNote) = schedule(listOf(note))
Schedule one note.
fun play(hz: Double, seconds: Double = 0.25, gain: Double = 1.0, afterSeconds: Double = 0.0)
Schedule a note starting afterSeconds from now, for seconds.
The convenience form, for playing an instrument from code rather than from a pattern.
fun play(hz: Double, seconds: Double = 0.25, gain: Double = 1.0, afterSeconds: Double = 0.0) {
val start = frame + (afterSeconds * sampleRate).roundToLong()
schedule(FaustNote(start, start + (seconds * sampleRate).roundToLong().coerceAtLeast(1L), hz, gain))
}
fun automate(changes: Collection<FaustParamChange>)
Schedule shared-parameter changes for their own frames — a cutoff sweep, a resonance, a detune.
The other half of "a pattern plays it". Notes are one kind of scheduled event and a parameter change
is another; both go through the same timeline, so a filter that opens on the beat opens on the sample
of the beat rather than up to 2.67ms after it. automationApplied counts what landed.
fun automate(changes: Collection<FaustParamChange>) {
if (changes.isEmpty() || closed.get()) return
incomingAutomation.addAll(changes)
}
var automationApplied: Long
Shared-parameter changes applied so far.
@Volatile
var automationApplied: Long = 0L
private set
fun pendingCount(): Int = pendingOwned + incomingNotes.size
Notes scheduled but not yet started — those handed over and not yet drained, plus those the audio thread is holding as of its last block.
Exact once the audio thread has caught up, which is what "nothing was left pending" means; during playback it can lag one block behind the truth, because it is assembled from a lock-free queue and a size the audio thread publishes rather than from a snapshot of both.
fun clearSchedule()
Drop every note and parameter change that has not happened yet. Sounding notes finish.
Takes effect within one block rather than instantly: the handoff queues are emptied here, and the audio thread's own ordered queues are emptied at the top of its next render, because they belong to it. A note whose frame falls inside the block already in flight may therefore still start.
fun clearSchedule() {
incomingNotes.clear()
incomingAutomation.clear()
clearRequested.set(true)
}
fun allNotesOff()
Gate every voice down now, and forget the schedule.
fun allNotesOff() {
clearSchedule()
if (closed.get()) return
context.enter()
try {
for (v in 0 until voices) {
val s = state[v]
s.retriggerAt = Long.MAX_VALUE
if (s.gateOn) {
fnSetParam.execute(v, controls.gate.index, 0f)
s.gateOn = false
s.endFrame = Long.MAX_VALUE
}
}
} finally {
runCatching { context.leave() }
}
}
fun setAll(control: FaustControl, value: Double)
Set a control on every voice — a cutoff, a resonance, anything that is not per-note.
This is the parameter half of "a pattern plays it": FaustPatternVoice patterns notes, and a
PatPattern of numbers can pattern this.
fun setAll(control: FaustControl, value: Double) {
if (closed.get()) return
val clamped = value.coerceIn(control.min, control.max).toFloat()
context.enter()
try {
for (v in 0 until voices) fnSetParam.execute(v, control.index, clamped)
} finally {
runCatching { context.leave() }
}
}
operator fun set(label: String, value: Double)
Set a control by label on every voice. Silently ignores an unknown name.
operator fun set(label: String, value: Double) {
program.ui.writable.firstOrNull {
it.label == label || it.qualifiedLabel == label || it.address == label
}?.let { setAll(it, value) }
}
rendering
fun render(outL: FloatArray, outR: FloatArray, numFrames: Int)
Render numFrames of the mix, advancing the bank's clock by exactly that many frames.
One Context.enter for the whole call however many voices sound and however many notes start inside
it; the block is split at event frames, not at parameter-queue boundaries.
body — 52 lines, from line 487
fun render(outL: FloatArray, outR: FloatArray, numFrames: Int) {
java.util.Arrays.fill(outL, 0, numFrames, 0f)
java.util.Arrays.fill(outR, 0, numFrames, 0f)
if (closed.get() || numFrames <= 0) return
context.enter()
try {
// Take ownership of everything handed over since the last block, and honour a clear request.
// One drain per block, no lock, and after this point the ordered queues are ours alone.
if (clearRequested.getAndSet(false)) {
pending.clear()
pendingAutomation.clear()
}
while (true) pending.add(incomingNotes.poll() ?: break)
while (true) pendingAutomation.add(incomingAutomation.poll() ?: break)
var done = 0
while (done < numFrames) {
val now = frameCounter.get() + done
applyDue(now)
var chunk = min(MAX_FRAMES, numFrames - done)
val next = nextEventFrame(now)
if (next != Long.MAX_VALUE) {
// Clamped before narrowing: a note with no end in sight is a legitimate schedule, and
// `(next - now).toInt()` on a far-future frame would overflow into a negative chunk.
chunk = min(chunk, (next - now).coerceAtMost(MAX_FRAMES.toLong()).toInt())
}
chunk = max(1, chunk)
mixChunk(outL, outR, done, chunk)
done += chunk
}
frameCounter.addAndGet(numFrames.toLong())
// Publish the queue depth for `pendingCount()`, which cannot read the queue itself.
pendingOwned = pending.size
} catch (t: Throwable) {
java.util.Arrays.fill(outL, 0, numFrames, 0f)
java.util.Arrays.fill(outR, 0, numFrames, 0f)
if (closed.compareAndSet(false, true)) {
println("Faust: '${program.name}' failed while rendering polyphonically and was stopped: $t")
}
} finally {
// try/catch rather than `runCatching`, which builds a `Result` — this is the audio thread, and
// an allocation here is a GC pause in the audio path rather than a performance nit.
try {
context.leave()
} catch (ignored: Throwable) {
// Leaving a context that was never entered is not worth taking the audio thread down for.
}
}
}
Start every note due at now and end every note whose time is up. Caller has entered the context.
Both halves happen at the same instant, which is what makes a legato pattern work: the note-off of the outgoing voice and the note-on of the incoming one are written in the same chunk boundary, so nothing is quantised relative to anything else.
source — 44 lines, from line 547
private fun applyDue(now: Long) {
// Stolen voices raise their gate again first: their rising edge is due at this frame and the
// envelope has to see it before anything else writes to the voice.
for (v in 0 until voices) {
val s = state[v]
if (s.retriggerAt <= now) {
fnSetParam.execute(v, controls.freq.index, clamp(controls.freq, s.pendingHz))
val gainControl = controls.gain
if (gainControl != null) fnSetParam.execute(v, gainControl.index, clamp(gainControl, s.pendingGain))
fnSetParam.execute(v, controls.gate.index, 1f)
s.gateOn = true
s.noteOnFrame = s.retriggerAt
s.endFrame = max(now + 1, s.pendingEnd)
s.hz = s.pendingHz
s.silent = false
s.retriggerAt = Long.MAX_VALUE
}
}
while (true) {
val head = pendingAutomation.peek() ?: break
if (head.frame > now) break
val change = pendingAutomation.poll() ?: break
val clamped = change.value.coerceIn(change.control.min, change.control.max).toFloat()
for (v in 0 until voices) fnSetParam.execute(v, change.control.index, clamped)
automationApplied++
}
for (v in 0 until voices) {
val s = state[v]
if (s.gateOn && s.endFrame <= now) {
fnSetParam.execute(v, controls.gate.index, 0f)
s.gateOn = false
}
}
while (true) {
val head = pending.peek() ?: break
if (head.startFrame > now) break
val note = pending.poll() ?: break
if (note.startFrame < now) lateNotes++
startNote(note, now)
}
var sounding = 0
for (v in 0 until voices) if (state[v].gateOn) sounding++
if (sounding > peakConcurrency) peakConcurrency = sounding
}
Allocate a voice for note and gate it on. Caller has entered the context.
The policy, stated because a voice allocator without a stated policy is a guess:
- a voice that is finished — gate down and its own output below
idleThreshold— preferred always, and picked round-robin-ish (lowest index) so a repeated single note reuses one voice and the rest stay cold; - else a voice that is released but still ringing — gate down, tail audible. Reusing it cuts that tail, counted as tailCuts; the one whose note started longest ago is chosen, which for released voices is the quietest tail;
- else steal the oldest sounding voice and count it in steals. A note is never dropped: an instrument that silently ignores a note is worse than one that runs out of voices audibly.
Stealing drops the gate for stealReleaseFrames samples, and that is not cosmetic
Writing freq to a voice whose gate is already up does not restart its envelope: Faust's
en.adsr and friends trigger on a rising edge, so gate = 1 over an already-high gate is a no-op.
The first version of this did exactly that, and it was measured: no click at all — the phasor and the
envelope both stay continuous, so max |x[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) - x[i-1]| across a steal was 1.03× the same
statistic in a steal-free passage — but also no attack. The stolen note simply glided in at the
old envelope level, which is not a note.
So a steal writes gate = 0 now and raises it stealReleaseFrames samples later, giving the
envelope a real rising edge — and at the default of one sample it does click, because Faust's
en.adsr resets to zero on a trigger rather than continuing from where it was. That click is measured
(0.99 of the local peak against 0.36 settled, 2.7×) and stealReleaseFrames is the knob that removes
it at the cost of latency. See its KDoc for both numbers.
source — 81 lines, from line 621
private fun startNote(note: FaustNote, now: Long) {
// 1. a finished voice: gate down and no longer moving air.
var chosen = -1
for (v in 0 until voices) {
val s = state[v]
if (!s.gateOn && s.silent && s.retriggerAt == Long.MAX_VALUE) { chosen = v; break }
}
// 2. else a released voice whose tail is still audible.
if (chosen < 0) {
var oldest = Long.MAX_VALUE
for (v in 0 until voices) {
val s = state[v]
if (!s.gateOn && s.retriggerAt == Long.MAX_VALUE && s.noteOnFrame < oldest) {
oldest = s.noteOnFrame
chosen = v
}
}
if (chosen >= 0) tailCuts++
}
if (chosen >= 0) {
val s = state[chosen]
fnSetParam.execute(chosen, controls.freq.index, clamp(controls.freq, note.hz))
val gainControl = controls.gain
if (gainControl != null) fnSetParam.execute(chosen, gainControl.index, clamp(gainControl, note.gain))
// Gate last: freq and gain must be in place on the sample the envelope opens.
fnSetParam.execute(chosen, controls.gate.index, 1f)
s.gateOn = true
s.noteOnFrame = now
s.endFrame = max(now + 1, note.endFrame)
s.hz = note.hz
s.silent = false
s.note = note
notesPlayed++
return
}
// 3. else steal the oldest sounding voice, gate down now and up again a sample later.
var oldest = Long.MAX_VALUE
for (v in 0 until voices) {
val s = state[v]
if (s.gateOn && s.noteOnFrame < oldest) {
oldest = s.noteOnFrame
chosen = v
}
}
if (chosen >= 0) {
steals++
val s = state[chosen]
fnSetParam.execute(chosen, controls.gate.index, 0f)
s.gateOn = false
s.retriggerAt = now + stealReleaseFrames.coerceAtLeast(1L)
s.pendingHz = note.hz
s.pendingGain = note.gain
s.pendingEnd = note.endFrame
s.silent = false
s.note = note
notesPlayed++
return
}
// 4. every voice is already mid-steal — which happens when a chord larger than the bank arrives on
// one frame. Supersede the pending note on whichever retriggers soonest, rather than dropping
// this one: the last note of an over-large chord is the one a person hears as "the note".
var soonest = Long.MAX_VALUE
for (v in 0 until voices) {
if (state[v].retriggerAt < soonest) {
soonest = state[v].retriggerAt
chosen = v
}
}
steals++
val s = state[chosen]
s.pendingHz = note.hz
s.pendingGain = note.gain
s.pendingEnd = note.endFrame
s.note = note
notesPlayed++
}
private fun clamp(control: FaustControl, value: Double): Float =
value.coerceIn(control.min, control.max).toFloat()
The next frame at which anything must happen, strictly after now; Long.MAX_VALUE when nothing
does.
A sentinel rather than a Long?: this runs once per chunk on the audio thread, where a
nullable Long boxes. render is still not allocation-free — synth.faust.poly's allocation
check asserts a bound on what a block costs, not zero — and what remains is the polyglot
buffer read rather than anything in this class. dev_docs/FAUST_MEASUREMENTS.md has the
per-block figures.
private fun nextEventFrame(now: Long): Long {
var next = Long.MAX_VALUE
// Two peeks, assigned to locals rather than returned as a Pair, which is the other thing that used
// to allocate here. No lock: both queues belong to this thread.
val note = pending.peek()
if (note != null && note.startFrame > now) next = note.startFrame
val change = pendingAutomation.peek()
if (change != null && change.frame > now && change.frame < next) next = change.frame
for (v in 0 until voices) {
val s = state[v]
if (s.retriggerAt > now && s.retriggerAt < next) next = s.retriggerAt
if (s.gateOn && s.endFrame > now && s.endFrame < next) next = s.endFrame
}
return next
}
Compute and sum every audible voice for count frames. Caller has entered the context.
source — 43 lines, from line 730
private fun mixChunk(outL: FloatArray, outR: FloatArray, offset: Int, count: Int) {
val gain = mixGain.toFloat()
for (v in 0 until voices) {
val s = state[v]
if (!s.gateOn && s.silent) continue
fnCompute.execute(v, count)
readChannel(v, 0, voiceL, count)
if (numOutputs > 1) readChannel(v, 1, voiceR, count) else System.arraycopy(voiceL, 0, voiceR, 0, count)
var peak = 0f
for (i in 0 until count) {
val l = voiceL[i]
val r = voiceR[i]
outL[offset + i] += l * gain
outR[offset + i] += r * gain
val a = max(abs(l), abs(r))
if (a > peak) peak = a
}
// A voice is finished only when its gate is down *and* it has actually stopped moving air.
//
// But `peak` is this chunk's maximum, and a chunk is as short as the gap between two scheduled
// events — one sample, when two notes land on adjacent frames. A releasing sawtooth crossing
// zero on that single sample reads `peak ≈ 0`, which would latch `silent` on a voice that is
// still audibly ringing, and `continue` above then skips it forever: the tail vanishes mid-decay
// and `tailCuts` never counts it, because nothing was reallocated. So a chunk too short to
// contain evidence is not allowed to *end* a voice; it can only keep one alive. The failure
// direction is now "compute a finished voice for a few more chunks", which costs a little and
// is inaudible, rather than "cut a sounding one", which is audible and untraceable.
if (count >= IDLE_DECISION_FRAMES) {
s.silent = !s.gateOn && peak < idleThreshold
} else if (peak >= idleThreshold) {
s.silent = false
}
}
}
private fun readChannel(voice: Int, channel: Int, out: FloatArray, count: Int) {
val byteOffset = voiceOutBase[voice] + channel.toLong() * MAX_FRAMES * 4L
voiceBuffers[voice].readBuffer(byteOffset, scratch, 0, count * 4)
scratchFloats.position(0)
scratchFloats.get(out, 0, count)
}
fun activeVoices(): Int = (0 until voices).count { state[it].gateOn || !state[it].silent }
How many voices are sounding right now (gate up, or still ringing).
fun voiceHz(voice: Int): Double = state[voice].hz
Which pitch a voice was last given, in Hz, or 0 if it has never played.
fun paramValue(voice: Int, control: FaustControl): Float
Read a control back out of one voice's WebAssembly memory.
The only way to check that a write landed where it was meant to rather than on whichever control
happened to sort first — which is what synth.faust.poly uses it for.
fun paramValue(voice: Int, control: FaustControl): Float {
require(voice in 0 until voices) { "voice must be in 0..${voices - 1}" }
if (closed.get()) return 0f
context.enter()
try {
return fnGetParam.execute(voice, control.index).asFloat()
} finally {
runCatching { context.leave() }
}
}
override fun close()
override fun close() {
if (!closed.compareAndSet(false, true)) return
runCatching { context.close(true) }
}
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"program" to program.name,
"voices" to voices,
"sampleRate" to sampleRate,
"notesPlayed" to notesPlayed,
"steals" to steals,
"peakConcurrency" to peakConcurrency
)
private fun source(code: String): Source =
Source.newBuilder("js", code, "faust-poly.js").buildLiteral()
}
data class FaustNote
One note for a FaustVoiceBank: when, for how long, at what pitch.
Times are absolute frames on the bank's own clock, not seconds, because that is the only clock in the system that cannot drift against the audio. A pattern's rational cycle time is converted to a frame once, by faustPatternNotes, and everything downstream is integer arithmetic.
data class FaustNote(
val startFrame: Long,
val endFrame: Long,
/** Pitch in Hz, written to the program's declared `freq` control (clamped to its declared range). */
val hz: Double,
/** Written to the program's declared `gain` control, if it declares one. */
val gain: Double = 1.0,
/** What the pattern atom said, carried through for debugging and for plots. */
val label: String = ""
) {
init
init {
require(endFrame > startFrame) { "FaustNote: endFrame must be > startFrame" }
require(hz.isFinite() && hz > 0.0) { "FaustNote: hz must be finite and > 0, got $hz" }
}
val lengthFrames: Long get() = endFrame - startFrame
override fun toString(): String
override fun toString(): String =
"FaustNote(${startFrame}..$endFrame, ${"%.2f".format(hz)}Hz, gain=${"%.2f".format(gain)}" +
(if (label.isBlank()) "" else ", '$label'") + ")"
}
data class FaustParamChange(val frame: Long, val control: FaustControl, val value: Double)
One shared-parameter change, on a frame of the bank's clock.
Written to every voice, because these are the front-panel controls rather than the per-note ones — a cutoff, a resonance, a reverb mix. Same timeline as the notes, so the same sample accuracy.
override fun toString(): String
override fun toString(): String = "FaustParamChange(@$frame ${control.label}=${"%.3f".format(value)})"
}
data class FaustPolyControls
The three controls a polyphonic Faust program has to declare, found by label.
freq / gate / gain is Faust's own convention — the same three names its -nvoices architecture
looks for — so a program written for faust2jaqt -nvoices 8 plays here unchanged.
Two things make finding them by label rather than by position mandatory rather than tidy. Faust lists
controls alphabetically within each group, so cutoff comes before freq however the source was
written and index 0 is whatever happens to sort first. And Faust wraps every program in an implicit
vgroup named after the module — which here is the box id — so a qualified label would read
box_a1b2c3/freq; FaustUi.parse already drops that wrapper, which is why the bare label works.
data class FaustPolyControls(
val freq: FaustControl,
val gate: FaustControl,
/** Optional: a program may bake its own level in. */
val gain: FaustControl?
) {
companion object
companion object {
fun of(program: FaustProgram): FaustPolyControls
Discover the three controls, or refuse with a message a person can act on.
@throws IllegalStateException naming what is missing, what the program did declare, and the one line of Faust that would fix it. The alternative — writing to control index 0 — produces silence, and there is nothing in silence to read.
fun of(program: FaustProgram): FaustPolyControls {
val writable = program.ui.writable
val freq = find(writable, "freq")
val gate = find(writable, "gate")
if (freq != null && gate != null) return FaustPolyControls(freq, gate, find(writable, "gain"))
val missing = listOfNotNull(
if (freq == null) "freq" else null,
if (gate == null) "gate" else null
)
val declared = if (writable.isEmpty()) "nothing" else writable.joinToString { "${it.label} (${it.type.faustName})" }
val fix = buildString {
if (freq == null) append("\n freq = hslider(\"freq [unit:Hz]\", 220, 40, 4000, 0.001);")
if (gate == null) {
append("\n gate = button(\"gate\");")
append("\n … and gate the envelope with it, e.g. en.adsr(0.005, 0.08, 0.6, 0.05, gate)")
}
}
error(
"Faust: '${program.name}' cannot be played polyphonically — a polyphonic program must " +
"declare ${missing.joinToString(" and ") { "`$it`" }}, which is Faust's own convention " +
"(the same names `-nvoices` looks for). It declares: $declared.$fix"
)
}
Faust lists controls alphabetically, so this matches on the name and never on the position. The address is checked too, so a program that puts its voice controls in a group still works.
private fun find(controls: List<FaustControl>, name: String): FaustControl? =
controls.firstOrNull { it.label.equals(name, ignoreCase = true) }
?: controls.firstOrNull { it.qualifiedLabel.equals(name, ignoreCase = true) }
?: controls.firstOrNull { it.address.substringAfterLast('/').equals(name, ignoreCase = true) }
}
}
class FaustPolyNode(val bank: FaustVoiceBank) : StereoNode()
A FaustVoiceBank as a node in Field's audio graph.
StereoNode rather than StereoRenderable for the same reason FaustNode is: the bank is stateful and
advances its own clock, so rendering it twice in one block must not advance it twice.
override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) {
bank.render(outL, outR, numFrames)
}
}
The polyphonic shim. Deliberately a separate program from FAUST_DSP_SHIM rather than a generalisation of it: that one is the measured single-voice path and there is no reason to put an array index in front of every access on it.
The important line is new WebAssembly.Module(bytes) once, outside the loop, and
new WebAssembly.Instance(module, env) N times inside it. Compiling the module is the expensive half and
it is shared; the instances are independent DSP states because Faust's wasm factory is built with
internal memory, so compute(0, …) addresses a struct at offset 0 of each instance's own memory.
source — 88 lines, from line 937
private const val FAUST_POLY_SHIM: String = """
var __polyVoices = [];
var __polyMaxFrames = 0, __polyInputs = 0, __polyOutputs = 0;
// Identical table to FAUST_DSP_SHIM's: Faust's wasm backend imports scalar math from `env`, and which
// entries a program needs depends on the program.
function __faustPolyEnv() {
var rem = function (a, b) { return a - Math.round(a / b) * b; };
var copysign = function (x, y) { return (y < 0) !== (x < 0) ? -x : x; };
var env = {
_abs: Math.abs, _acosf: Math.acos, _asinf: Math.asin, _atanf: Math.atan, _atan2f: Math.atan2,
_ceilf: Math.ceil, _cosf: Math.cos, _expf: Math.exp, _exp2f: function (x) { return Math.pow(2, x); },
_exp10f: function (x) { return Math.pow(10, x); }, _floorf: Math.floor,
_fmodf: function (a, b) { return a % b; }, _logf: Math.log, _log2f: Math.log2, _log10f: Math.log10,
_max_f: Math.max, _min_f: Math.min, _powf: Math.pow, _remainderf: rem, _rintf: Math.round,
_roundf: Math.round, _sinf: Math.sin, _sqrtf: Math.sqrt, _tanf: Math.tan, _truncf: Math.trunc,
_acoshf: Math.acosh, _asinhf: Math.asinh, _atanhf: Math.atanh,
_coshf: Math.cosh, _sinhf: Math.sinh, _tanhf: Math.tanh,
_isnanf: function (x) { return isNaN(x) ? 1 : 0; },
_isinff: function (x) { return !isFinite(x) && !isNaN(x) ? 1 : 0; },
_copysignf: copysign
};
var single = Object.keys(env);
for (var i = 0; i < single.length; i++) {
var name = single[i];
if (name.charAt(name.length - 1) === "f") env[name.substring(0, name.length - 1)] = env[name];
}
env._max_ = Math.max;
env._min_ = Math.min;
env.table = new WebAssembly.Table({ initial: 0, element: "anyfunc" });
return env;
}
function __faustPolySetup(voices, sampleRate, declaredInputs, declaredOutputs, maxFrames) {
try {
var bytes = new Uint8Array(Java.from(__polyWasmBytes));
var module = new WebAssembly.Module(bytes);
__polyMaxFrames = maxFrames;
__polyVoices = [];
for (var v = 0; v < voices; v++) {
var instance = new WebAssembly.Instance(module, { env: __faustPolyEnv() });
var ex = instance.exports;
var mem = ex.memory;
var ins = ex.getNumInputs ? ex.getNumInputs(0) : declaredInputs;
var outs = ex.getNumOutputs ? ex.getNumOutputs(0) : declaredOutputs;
var channels = Math.max(1, Math.max(ins, outs));
var ptrBytes = 2 * channels * 4;
var audioBytes = 2 * channels * maxFrames * 4;
// memory.grow detaches every existing view, so it happens before any view is made — once, here.
if (((mem.buffer.byteLength - (ptrBytes + audioBytes + 64)) | 0) < 0) {
mem.grow(Math.ceil((ptrBytes + audioBytes + 65536) / 65536));
}
var total = mem.buffer.byteLength;
var scratch = (total - (ptrBytes + audioBytes) - 16) & ~15;
var outPtrArray = scratch;
var inPtrArray = scratch + channels * 4;
var outBase = (inPtrArray + channels * 4 + 15) & ~15;
var inBase = outBase + channels * maxFrames * 4;
var ptrs = new Int32Array(mem.buffer, outPtrArray, 2 * channels);
for (var c = 0; c < channels; c++) {
ptrs[c] = outBase + c * maxFrames * 4;
ptrs[channels + c] = inBase + c * maxFrames * 4;
}
ex.init(0, sampleRate);
__polyVoices.push({
exports: ex, memory: mem,
outPtrArray: outPtrArray, inPtrArray: inPtrArray,
outBase: outBase, inBase: inBase
});
__polyInputs = ins;
__polyOutputs = outs;
}
return { error: null, inputs: __polyInputs, outputs: __polyOutputs, voices: __polyVoices.length };
} catch (e) {
return { error: String(e) + (e && e.stack ? " | " + e.stack : ""), inputs: 0, outputs: 0, voices: 0 };
}
}
function __faustPolyCompute(v, count) {
var i = __polyVoices[v];
i.exports.compute(0, count, i.inPtrArray, i.outPtrArray);
}
function __faustPolySetParam(v, index, value) { __polyVoices[v].exports.setParamValue(0, index, value); }
function __faustPolyGetParam(v, index) { return __polyVoices[v].exports.getParamValue(0, index); }
function __faustPolyBuffer(v) { return __polyVoices[v].memory.buffer; }
function __faustPolyOffsets(v) { return { outBase: __polyVoices[v].outBase, inBase: __polyVoices[v].inBase }; }
"""