FaustEngine.kt
src/jvmMain/kotlin/afield/audio/synth/FaustEngine.kt · 693 lines
@file:afield.runtime.completion.CompletionCategory("Synthesis")
package afield.audio.synth
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.graalvm.polyglot.Context
import org.graalvm.polyglot.Engine
import org.graalvm.polyglot.HostAccess
import org.graalvm.polyglot.PolyglotAccess
import org.graalvm.polyglot.Source
import org.graalvm.polyglot.Value
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import kotlin.concurrent.thread
The Faust compiler, running inside Field.
Faust is a functional language for signal processing that compiles to
efficient code. Field embeds the compiler itself as WebAssembly, in
src/jvmMain/resources/faust/ with a PROVENANCE.md beside it, and runs it in the same GraalVM
runtime that already hosts dough.wasm. There is nothing to install on any platform: the build
that loads is the one committed in the repository, and synth.faust.smoke asserts its version
against EXPECTED_LIBFAUST_VERSION.
Two halves, opposite performance
Measured on an aarch64 Mac under GraalVM CE 25.0.2, and the reason the object is shaped the way it is:
- Generated DSP is fast.
computeon a compiled program takes ~7.6µs of the 2667µs a 128-frame block has at 48kHz; end to end through FaustDsp it is ~60µs, and what separates those two figures is the JVM↔WebAssembly boundary rather than Faust.synth.faust.smokeasserts a floor of ten times realtime, so playing is not a performance problem here. - The compiler is slow.
compilesettles at ~600ms once Truffle has warmed, against ~30ms for the same call under Node's V8, and the first compile of a process pays 13–22s of warm-up.
Three things follow:
- The compiler gets its own GraalVM context, never shared with a FaustDsp. A 600ms compile holding a lock the audio thread wants every 2.67ms would be audible as a dropout.
warmInBackgroundpays the 13–22s while Field is starting, rather than at the moment someone first runs a box.compileis memoised on the hash of (source, arguments), so re-running an unchanged box, or undoing back to a program already seen, costs nothing.
Errors
The vendored compiler is an Emscripten build with exception catching disabled, so every failure
arrives as an opaque "Exception catching is disabled" RuntimeError. The real message is only
available afterwards, from getErrorAfterException(), and it is well formed:
"probe:2 : ERROR : undefined symbol : sawtoothX". FaustCompileError parses that into something
an editor marker can use.
object FaustEngine
Compiles Faust source to a FaustProgram, once per distinct (source, arguments) pair.
Process-wide, and boots the compiler on first use. warmInBackground moves that cost to startup;
shutdown releases the context so a headless run can exit.
object FaustEngine {
const val EXPECTED_LIBFAUST_VERSION: String = "2.86.2"
The libfaust build vendored in resources/faust; asserted by the smoke test.
const val DEFAULT_ARGS: String = "-I libraries/"
Default compiler arguments.
-I libraries/ points at the standard library inside the Emscripten preload package, which is
how import("stdfaust.lib") resolves without a filesystem.
private val lock = Any()
How many separate evals to give Emscripten's ready promise. See ensureLoadedLocked.
private const val STARTUP_POLL_ATTEMPTS = 64
internal val sharedEngine: Engine by lazy
One Engine shared by the compiler context and every FaustDsp context, so Truffle's compiled code for the wasm runtime is shared rather than re-JITted per DSP. This matters: it is the difference between one warm-up and one per box.
internal val sharedEngine: Engine by lazy {
Engine.newBuilder()
.allowExperimentalOptions(true)
.option("engine.WarnInterpreterOnly", "false")
.build()
}
private var context: Context? = null
private var compiler: Value? = null
private var version: String = ""
private val cache = ConcurrentHashMap<String, FaustProgram>()
@Volatile
private var warming = false
fun warmInBackground()
Start loading and warming the compiler on a background thread.
Idempotent and non-blocking. Call it early — the first compile in a process is 13–22s of Truffle warm-up, and the only good time to spend that is before anybody is waiting.
fun warmInBackground() {
synchronized(lock) {
if (warming || compiler != null) return
warming = true
}
thread(isDaemon = true, name = "faust-warm") {
runCatching {
// A trivial program, but a real compile: it is the compile path itself that is slow
// to warm, not the loading of the module.
compile("__warm", "process = _;")
}.onFailure { println("Faust: background warm-up failed: $it") }
}
}
fun libfaustVersion(): String
The libfaust version actually loaded, e.g. 2.86.2. Boots the compiler if needed.
fun libfaustVersion(): String {
ensureLoaded()
return version
}
fun compile(name: String, source: String, args: String = DEFAULT_ARGS): FaustProgram
Compile Faust source to a WebAssembly module plus its metadata.
@param name module name; also the name Faust puts in front of a diagnostic's line number
@param source Faust source, with any #faust header already removed — see stripFaustHeader
@throws FaustCompileError with the compiler's own message and, where it gave one, a line
fun compile(name: String, source: String, args: String = DEFAULT_ARGS): FaustProgram {
val key = cacheKey(source, args)
cache[key]?.let { return it.copy(name = name) }
val program = synchronized(lock) {
// Re-check inside the lock: two boxes running the same code at once should compile once.
cache[key]?.let { return@synchronized it }
ensureLoadedLocked()
val ctx = context!!
ctx.enter()
try {
compileLocked(name, source, args).also { cache[key] = it }
} finally {
ctx.leave()
}
}
return program.copy(name = name)
}
fun blockDiagram(name: String, source: String, args: String = DEFAULT_ARGS): Map<String, String>
The Faust block diagram for source, as SVG.
Returns the top-level process.svg and every sub-diagram it links to, keyed by filename, so a
viewer can follow the links Faust puts in the SVG without calling back into the compiler.
generateAuxFiles throws and still succeeds: it writes the SVG set into the in-memory
filesystem and only then fails trying a C++ backend this build does not include. So the throw
is swallowed and the files are read regardless — if they are genuinely absent the map comes
back empty, which callers treat as "no diagram" rather than as an error.
fun blockDiagram(name: String, source: String, args: String = DEFAULT_ARGS): Map<String, String> {
synchronized(lock) {
ensureLoadedLocked()
val ctx = context!!
ctx.enter()
try {
val safe = sanitiseModuleName(name)
val result = ctx.eval(js("__faustDiagram(${jsString(safe)}, ${jsString(source)}, ${jsString(args)})"))
if (!result.hasMembers()) return emptyMap()
val out = LinkedHashMap<String, String>()
for (fileName in result.memberKeys) {
out[fileName] = result.getMember(fileName).asString()
}
return out
} finally {
ctx.leave()
}
}
}
fun clearCache()
Drop the memoised programs. Used by tests that need a cold-ish measurement.
fun clearCache() {
cache.clear()
}
fun shutdown()
Close the compiler and release its context.
Field itself never needs this — the compiler is process-wide and boots once. It exists so a headless run can exit naturally instead of being held open by a live GraalVM context, which is the difference between a smoke test that passes and one that hangs.
fun shutdown() {
synchronized(lock) {
compiler = null
version = ""
warming = false
context?.let { ctx -> runCatching { ctx.close(true) } }
context = null
cache.clear()
}
}
internals
source — 69 lines, from line 215
private fun cacheKey(source: String, args: String): String {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(source.toByteArray(Charsets.UTF_8))
digest.update(0)
digest.update(args.toByteArray(Charsets.UTF_8))
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun ensureLoaded() {
synchronized(lock) { ensureLoadedLocked() }
}
private fun ensureLoadedLocked() {
if (compiler != null) return
val ctx = Context.newBuilder("js", "wasm")
.engine(sharedEngine)
.allowExperimentalOptions(true)
.allowHostAccess(HostAccess.ALL)
.allowHostClassLookup { true }
.allowAllAccess(true)
.allowPolyglotAccess(PolyglotAccess.ALL)
.option("js.webassembly", "true")
.option("wasm.UseUnsafeMemory", "true")
.build()
ctx.enter()
try {
ctx.eval(js(FAUST_HOST_SHIM))
val bindings = ctx.getBindings("js")
bindings.putMember("__faustWasmBytes", resourceBytes("libfaust-wasm.wasm"))
bindings.putMember("__faustDataBytes", resourceBytes("libfaust-wasm.data"))
ctx.eval(js(resourceText("libfaust-wasm.js")))
ctx.eval(js("__faustBoot()"))
// Each __faustPoll() is a fresh eval, which is what lets the pending microtask that
// resolves Emscripten's ready promise actually run. In practice it is done on the first
// poll; the loop is here so a build that defers more work cannot turn into a silent hang.
var ready = false
var failure: String? = null
for (attempt in 0 until STARTUP_POLL_ATTEMPTS) {
val state = ctx.eval(js("__faustPoll()"))
val error = state.getMember("error")
if (!error.isNull) {
failure = error.asString()
break
}
if (state.getMember("done").asBoolean() && state.getMember("hasCompiler").asBoolean()) {
ready = true
break
}
}
if (failure != null) error("libfaust failed to start: $failure")
if (!ready) error("libfaust did not finish starting after $STARTUP_POLL_ATTEMPTS polls")
compiler = bindings.getMember("__faustCompiler")
version = ctx.eval(js("__faustCompiler.version()")).asString()
context = ctx
} catch (t: Throwable) {
runCatching { ctx.leave() }
runCatching { ctx.close(true) }
throw IllegalStateException("Faust: could not start the compiler: ${t.message}", t)
}
// Deliberately not in a finally: on success the context stays open for the process, and the
// enter() above is balanced here.
runCatching { ctx.leave() }
}
Caller holds lock and has entered the context.
private fun compileLocked(name: String, source: String, args: String): FaustProgram {
val ctx = context!!
val safe = sanitiseModuleName(name)
val result = ctx.eval(
js("__faustCompile(${jsString(safe)}, ${jsString(source)}, ${jsString(args)})")
)
val error = result.getMember("error")
if (!error.isNull) {
throw FaustCompileError.parse(error.asString(), safe)
}
val wasm = result.getMember("wasm").let { value ->
val n = value.arraySize.toInt()
ByteArray(n) { i -> (value.getArrayElement(i.toLong()).asInt() and 0xFF).toByte() }
}
val dspJson = result.getMember("json").asString()
return FaustProgram(
name = safe,
source = source,
wasm = wasm,
dspJson = dspJson,
ui = FaustUi.parse(dspJson)
)
}
private fun js(code: String): Source = Source.newBuilder("js", code, "faust-host.js").buildLiteral()
private fun jsString(s: String): String = Json.encodeToString(kotlinx.serialization.serializer(), s)
Faust uses the module name as a C identifier and as the diagnostic prefix, so a box title or a path would break the compile in a way that looks like a syntax error in the user's program.
private fun sanitiseModuleName(name: String): String {
val cleaned = name.map { if (it.isLetterOrDigit() || it == '_') it else '_' }.joinToString("")
val trimmed = cleaned.trimStart('0', '1', '2', '3', '4', '5', '6', '7', '8', '9')
return if (trimmed.isEmpty()) "faust" else trimmed.take(48)
}
private fun resourceBytes(fileName: String): ByteArray {
FaustEngine::class.java.getResourceAsStream("/faust/$fileName")?.use { return it.readBytes() }
val onDisk = File("./src/jvmMain/resources/faust/$fileName")
require(onDisk.isFile) { "faust/$fileName not on the classpath or at ${onDisk.absolutePath}" }
return onDisk.readBytes()
}
private fun resourceText(fileName: String): String = resourceBytes(fileName).toString(Charsets.UTF_8)
}
fun stripFaustHeader(source: String): Pair<String, Int>
Remove a leading #faust marker line, returning the source and the number of lines removed.
Unlike Sonic Pi — where # is a comment and so #sonicpi can stay in the program that Sonic Pi
then counts — # is not a comment in Faust, and a #faust line would be a syntax error. So the
header has to go, and every line number the compiler reports afterwards is short by however many
lines went with it. This is the RectSL arrangement rather than the Sonic Pi one.
@return the source with the header removed, and the line offset to add to a reported line to get back to the line of the original text
fun stripFaustHeader(source: String): Pair<String, Int> {
val lines = source.lines()
var i = 0
// Leading blank lines are part of the header: a Kotlin """ string usually starts with a newline.
while (i < lines.size && lines[i].isBlank()) i++
if (i < lines.size && lines[i].trimStart().startsWith("#faust", ignoreCase = true)) {
return lines.drop(i + 1).joinToString("\n") to (i + 1)
}
return source to 0
}
data class FaustProgram
A compiled Faust program: the WebAssembly module and what the compiler said about it.
data class FaustProgram(
val name: String,
/** The source that was compiled, with any `#faust` header already removed. */
val source: String,
val wasm: ByteArray,
val dspJson: String,
val ui: FaustUi
) {
val inputs: Int get() = ui.inputs
val outputs: Int get() = ui.outputs
override fun equals(other: Any?): Boolean
Data class over a ByteArray: identity by content, so two programs that compiled to the same module compare equal rather than by array identity.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is FaustProgram) return false
return name == other.name && dspJson == other.dspJson && wasm.contentEquals(other.wasm)
}
override fun hashCode(): Int
override fun hashCode(): Int = 31 * (31 * name.hashCode() + dspJson.hashCode()) + wasm.contentHashCode()
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"name" to name,
"wasmBytes" to wasm.size,
"inputs" to inputs,
"outputs" to outputs,
"controls" to ui.controls.size
)
}
class FaustCompileError
A Faust compile error, with the line the compiler blamed.
line is a line of the source that was handed to the compiler, so a caller that stripped a
#faust header has to add its offset back before putting a marker on a box. See
stripFaustHeader.
class FaustCompileError(
val faustMessage: String,
val line: Int?,
val raw: String
) : Exception(faustMessage) {
companion object
companion object {
e.g. "probe:2 : ERROR : undefined symbol : sawtoothX"
private val located = Regex("""^\s*(\S+?):(\d+)\s*:\s*(.*)$""", RegexOption.DOT_MATCHES_ALL)
fun parse(raw: String, moduleName: String): FaustCompileError
fun parse(raw: String, moduleName: String): FaustCompileError {
val trimmed = raw.trim()
val first = trimmed.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: trimmed
val match = located.matchEntire(first)
if (match != null && match.groupValues[1].endsWith(moduleName)) {
return FaustCompileError(
faustMessage = match.groupValues[3].trim().removePrefix("ERROR :").trim(),
line = match.groupValues[2].toIntOrNull(),
raw = trimmed
)
}
return FaustCompileError(trimmed.ifEmpty { "unknown Faust compile error" }, null, trimmed)
}
}
}
data class FaustUi
What a Faust program declares about its own controls, flattened.
Faust programs describe their user interface in the source — hslider, button, hgroup and so
on — and the compiler hands that back as JSON. This is the thing that makes a Faust box feel like
part of Field rather than a text area that makes noise: the controls are generated, not written.
See faustControls for the bridge onto Field's own control surface.
data class FaustUi(
val inputs: Int,
val outputs: Int,
/** Every control, groups flattened. Alphabetical within a group — see [writable]. */
val controls: List<FaustControl>
) {
val writable: List<FaustControl> get() = controls.filter { !it.isReadOnly }
Controls a person can move.
In the order Faust lists them, which is alphabetical within each group rather than the order
they were declared — cutoff comes before freq however the source was written. Look a control
up by label rather than by position.
val meters: List<FaustControl> get() = controls.filter { it.isReadOnly }
hbargraph/vbargraph: values the DSP reports back, for meters.
companion object
companion object {
private val json = Json { ignoreUnknownKeys = true }
fun parse(dspJson: String): FaustUi
body — 54 lines, from line 452
fun parse(dspJson: String): FaustUi {
val root = json.parseToJsonElement(dspJson).jsonObject
val controls = mutableListOf<FaustControl>()
// Faust wraps every program in one implicit group named after the module. That name is
// Field's own (`box_a1b2c3`), so carrying it into the labels would put the box id in
// front of every slider. Skip the wrapper and keep only groups the author wrote.
root["ui"]?.jsonArray?.let { top ->
val implicit = top.size == 1 &&
(top[0] as? JsonObject)?.get("type")?.jsonPrimitive?.contentOrNull?.endsWith("group") == true
if (implicit) {
val wrapper = top[0].jsonObject
collect(wrapper["items"]?.jsonArray ?: JsonArray(emptyList()), emptyList(), controls)
} else {
collect(top, emptyList(), controls)
}
}
return FaustUi(
inputs = root["inputs"]?.jsonPrimitive?.intOrNull ?: 0,
outputs = root["outputs"]?.jsonPrimitive?.intOrNull ?: 0,
controls = controls
)
}
private fun collect(items: JsonArray, path: List<String>, out: MutableList<FaustControl>) {
for (element in items) {
val item = element as? JsonObject ?: continue
val type = item["type"]?.jsonPrimitive?.contentOrNull ?: continue
val label = item["label"]?.jsonPrimitive?.contentOrNull ?: ""
if (type.endsWith("group")) {
// Groups only contribute their label to the path; Field's control surface is
// flat, so nesting becomes a qualified name rather than a nested widget.
collect(item["items"]?.jsonArray ?: JsonArray(emptyList()), path + label, out)
continue
}
val meta = item["meta"]?.jsonArray.orEmpty()
out += FaustControl(
type = FaustControlType.of(type) ?: continue,
label = label,
groupPath = path,
address = item["address"]?.jsonPrimitive?.contentOrNull ?: "/$label",
index = item["index"]?.jsonPrimitive?.intOrNull ?: -1,
init = item["init"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
min = item["min"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
max = item["max"]?.jsonPrimitive?.doubleOrNull ?: 1.0,
step = item["step"]?.jsonPrimitive?.doubleOrNull ?: 0.01,
unit = meta.metaValue("unit"),
scale = FaustScale.of(meta.metaValue("scale")),
tooltip = meta.metaValue("tooltip")
)
}
}
private fun List<kotlinx.serialization.json.JsonElement>?.orEmpty(): List<kotlinx.serialization.json.JsonElement> =
this ?: emptyList()
Faust metadata is a list of single-entry objects, not one object.
private fun List<kotlinx.serialization.json.JsonElement>.metaValue(key: String): String? {
for (entry in this) {
val obj = entry as? JsonObject ?: continue
obj[key]?.jsonPrimitive?.contentOrNull?.let { return it }
}
return null
}
}
}
enum class FaustControlType(val faustName: String)
The Faust control widgets Field knows how to show.
enum class FaustControlType(val faustName: String) {
HSLIDER("hslider"),
VSLIDER("vslider"),
NENTRY("nentry"),
BUTTON("button"),
CHECKBOX("checkbox"),
HBARGRAPH("hbargraph"),
VBARGRAPH("vbargraph");
companion object
fun of(name: String): FaustControlType? = entries.firstOrNull { it.faustName == name }
enum class FaustScale
[scale:...] metadata — how a slider's travel maps onto its value.
enum class FaustScale {
LINEAR, LOG, EXP;
companion object
fun of(name: String?): FaustScale
fun of(name: String?): FaustScale = when (name?.lowercase()) {
"log" -> LOG
"exp" -> EXP
else -> LINEAR
}
}
}
data class FaustControl
One control declared by a Faust program.
index is the byte offset of this parameter inside the DSP's own WebAssembly memory, which is why
setting a value is a float store rather than a message — see FaustDsp.setParam.
data class FaustControl(
val type: FaustControlType,
val label: String,
val groupPath: List<String>,
val address: String,
val index: Int,
val init: Double,
val min: Double,
val max: Double,
val step: Double,
val unit: String? = null,
val scale: FaustScale = FaustScale.LINEAR,
val tooltip: String? = null
) {
val isReadOnly: Boolean
A bargraph is the DSP talking to the user, not the other way round.
val isReadOnly: Boolean
get() = type == FaustControlType.HBARGRAPH || type == FaustControlType.VBARGRAPH
val isMomentary: Boolean get() = type == FaustControlType.BUTTON
val isToggle: Boolean get() = type == FaustControlType.CHECKBOX
val qualifiedLabel: String
Label with its groups, e.g. filter/cutoff, for a flat control surface.
val qualifiedLabel: String
get() = (groupPath.filter { it.isNotBlank() } + label).joinToString("/")
val displayLabel: String
Label with its unit, e.g. cutoff (Hz).
val displayLabel: String
get() = if (unit.isNullOrBlank()) qualifiedLabel else "$qualifiedLabel ($unit)"
}
The host side of the Emscripten contract, and the reason this is a hand-written shim rather than an import of faustwasm's own loader: that loader is an ES module graph that expects a bundler, a filesystem and fetch. All Field needs is the factory, called with both binaries as bytes.
Environment detection is the sharp edge. Emscripten picks one of web / worker / node / shell at
startup; this build was not compiled with 'shell', and looking like 'node' sends it to
require('fs'). So it is shown a web environment: an object window, a document with a script
src, and a location that has a pathname — the .data preloader calls
window.location.pathname.toString(). Deliberately no importScripts (that reads as a worker)
and no process (that reads as node). Because wasmBinary and getPreloadedPackage are supplied
directly, no fetch or XHR path is ever reached.
source — 100 lines, from line 593
private const val FAUST_HOST_SHIM: String = """
var window = globalThis;
var document = { currentScript: { src: "file:///libfaust-wasm.js" } };
var location = { href: "file:///libfaust-wasm.js", pathname: "/libfaust-wasm.js" };
// Emscripten defers startup through these when it has outstanding dependencies. In practice this
// build needs none, but a queue that is never drained would hang rather than fail loudly.
var __faustTimers = [];
function setTimeout(fn, ms) { __faustTimers.push(fn); return __faustTimers.length; }
function clearTimeout(id) {}
function setImmediate(fn) { __faustTimers.push(fn); return __faustTimers.length; }
function __faustDrain() {
var n = 0;
while (__faustTimers.length > 0 && n < 10000) { var f = __faustTimers.shift(); try { f(); } catch (e) {} n++; }
return n;
}
var performance = { now: function () { return 0; } };
var crypto = { getRandomValues: function (a) { for (var i = 0; i < a.length; i++) a[i] = 0; return a; } };
var __faustState = { done: false, error: null, mod: null };
var __faustCompiler = null;
function __faustBoot() {
try {
FaustModule({
wasmBinary: new Uint8Array(Java.from(__faustWasmBytes)).buffer,
getPreloadedPackage: function (name, size) {
return new Uint8Array(Java.from(__faustDataBytes)).buffer;
},
print: function (t) { console.log("[faust] " + t); },
printErr: function (t) { console.log("[faust] " + t); }
}).then(function (m) {
__faustState.done = true;
__faustState.mod = m;
__faustCompiler = new m.libFaustWasm();
}, function (e) {
__faustState.error = "libfaust rejected: " + String(e);
});
} catch (e) {
__faustState.error = "libfaust threw: " + String(e);
}
__faustDrain();
return !__faustState.error;
}
// Startup completes on the microtask queue, and that only runs as an eval unwinds — so readiness
// cannot be observed inside __faustBoot, only from a *later* eval. Hence a poll rather than a
// return value.
function __faustPoll() {
__faustDrain();
return { done: !!__faustState.done, error: __faustState.error, hasCompiler: !!__faustCompiler };
}
// Every libfaust call has to funnel through this. The build has exception catching disabled, so a
// thrown error carries no information at all; the real message is only in getErrorAfterException(),
// and the compiler must be told to clean up before it is used again.
function __faustGuard(fn) {
try {
return { error: null, value: fn() };
} catch (e) {
var message = "";
try { message = String(__faustCompiler.getErrorAfterException() || ""); } catch (e2) {}
try { __faustCompiler.cleanupAfterException(); } catch (e2) {}
if (!message) message = String(e);
return { error: message, value: null };
}
}
function __faustCompile(name, source, args) {
var r = __faustGuard(function () { return __faustCompiler.createDSPFactory(name, source, args, true); });
if (r.error) return { error: r.error, wasm: null, json: null };
var factory = r.value;
// `data` is an embind std::vector<uint8_t>, not a typed array: size()/get(i), and it must be
// deleted or the compiler's heap grows with every compile.
var vec = factory.data;
var n = vec.size();
var bytes = new Uint8Array(n);
for (var i = 0; i < n; i++) bytes[i] = vec.get(i);
var json = factory.json;
try { vec.delete(); } catch (e) {}
return { error: null, wasm: bytes, json: json };
}
function __faustDiagram(name, source, args) {
// Throws *after* writing the SVGs — it goes on to try a C++ backend that is not built. So the
// error is ignored and the filesystem is read either way.
__faustGuard(function () { return __faustCompiler.generateAuxFiles(name, source, args + " -svg"); });
var dir = name + "-svg";
var out = {};
try {
var names = __faustState.mod.FS.readdir(dir);
for (var i = 0; i < names.length; i++) {
var f = names[i];
if (f === "." || f === "..") continue;
try { out[f] = __faustState.mod.FS.readFile(dir + "/" + f, { encoding: "utf8" }); } catch (e) {}
}
} catch (e) {}
return out;
}
"""