feat(playground): Faust WASM build pipeline + param auto-discovery (meml-5s3)

Adds the Faust DSP toolchain infrastructure: placeholder additive and FM DSP
files, build.sh (faust -lang wasm per .dsp), faustJsonToParamMeta() to convert
Faust JSON UI trees into the standard paramMeta format, FaustEngineBase
(SynthEngine subclass wiring init/setParam/noteOn/noteOff through AudioWorklet
messages), and FaustWorkletProcessor base class for concrete engine processors.
This commit is contained in:
w1n5t0n 2026-04-03 17:30:23 +01:00
parent c69b46f8cc
commit 55bf1b5d48
8 changed files with 931 additions and 0 deletions

154
playground/faust/README.md Normal file
View file

@ -0,0 +1,154 @@
# playground/faust — Faust DSP Build Pipeline
This directory contains Faust DSP source files and the toolchain for compiling
them to WebAssembly for use in the MEMLNaut playground.
## Required Tools
| Tool | Version | Purpose |
|---------|----------|-------------------------------------------------|
| `faust` | >= 2.60 | Compiles `.dsp``.wasm` + `.json` |
| `emcc` | >= 3.1.x | Optional — needed only if linking extra C++ code |
`emcc` is already available at `/usr/lib/emscripten/emcc` on this system.
`faust` is not currently installed — use `nix-shell -p faust` for a one-off
build, or `nix profile install nixpkgs#faust` to install permanently.
## How to Compile
```bash
cd playground/faust
./build.sh
```
This compiles every `.dsp` file in the directory, producing alongside it:
- `<name>.wasm` — the compiled DSP binary (loaded as an AudioWorkletNode)
- `<name>.json` — the Faust UI descriptor (consumed by `faustJsonToParamMeta`)
- `<name>.js` — JS glue / AudioWorklet wrapper generated by faust
## DSP Files
| File | Status | Description |
|------------------|-------------|-----------------------------------------------------|
| `additive.dsp` | Placeholder | 4-harmonic sine bank (2 params). Full engine: meml-pj4 |
| `fm-matrix.dsp` | Placeholder | 2-op FM synth (4 params). Full engine: meml-wgg |
## Output Format — `.json` Descriptor
Faust's `-json` flag emits a UI descriptor tree. Example:
```json
{
"name": "additive",
"version": "2.75.7",
"options": "-vec",
"size": "0",
"inputs": "0",
"outputs": "2",
"meta": [...],
"ui": [
{
"type": "vgroup",
"label": "additive",
"items": [
{
"type": "hslider",
"label": "freq",
"address": "/additive/freq",
"meta": [{"unit": "Hz"}],
"init": 220,
"min": 20,
"max": 4000,
"step": 0.1
},
{
"type": "hslider",
"label": "amp",
"address": "/additive/amp",
"init": 0.5,
"min": 0,
"max": 1,
"step": 0.001
}
]
}
]
}
```
## How `faustJsonToParamMeta` Consumes the JSON
`playground/js/synth/faust-param-meta.js` exports:
```js
import { faustJsonToParamMeta, loadFaustParamMeta } from './faust-param-meta.js';
// From a pre-parsed object:
const paramMeta = faustJsonToParamMeta(faustJson);
// Or fetch + parse in one step:
const paramMeta = await loadFaustParamMeta('faust/additive.json');
```
`faustJsonToParamMeta` recursively walks the `ui` tree, collects all
`hslider` / `vslider` / `nentry` items, and returns:
```js
[
{ id: 'freq', name: 'freq', min: 20, max: 4000, init: 220, curve: 0.5, group: 'additive' },
{ id: 'amp', name: 'amp', min: 0, max: 1, init: 0.5, curve: 0.5, group: 'additive' },
]
```
This is the standard `paramMeta` format used throughout the playground
(`SynthEngine.paramMeta`, preset system, group drawer, etc.).
## FaustEngineBase Loading Pattern
`playground/js/synth/faust-engine-base.js` provides a base class for any
engine compiled with this pipeline:
```js
import { FaustEngineBase } from './js/synth/faust-engine-base.js';
class AdditiveEngine extends FaustEngineBase {
constructor() {
super({
id: 'additive',
displayName: 'Additive',
wasmUrl: 'faust/additive.wasm',
jsonUrl: 'faust/additive.json',
workletUrl: 'faust/additive-processor.js', // AudioWorklet file
processorName: 'additive-processor', // registerProcessor() name
});
}
}
const engine = new AdditiveEngine();
await engine.init(audioCtx); // fetches JSON + WASM, loads worklet
engine.noteOn(69, 0.8); // A4, velocity 0.8
engine.setParam(0, 0.6); // normalized [0,1] → maps to param range
engine.noteOff(69);
```
The `init()` call:
1. Fetches `jsonUrl` → calls `loadFaustParamMeta()` → populates `engine.paramMeta`
2. Fetches `wasmUrl` → transfers bytes to AudioWorklet
3. Registers `workletUrl` with `audioCtx.audioWorklet.addModule()`
4. Creates an `AudioWorkletNode` and connects it to `audioCtx.destination`
5. Sends `{ type: 'init', wasmBytes, sampleRate }` to the worklet
6. Waits for the worklet to reply `{ type: 'ready' }` (10 s timeout)
## AudioWorklet Processor Base
`faust-worklet-processor.js` (this directory) defines `FaustWorkletProcessor`,
a base class for concrete engine worklets. It handles:
- `{ type: 'init', wasmBytes, sampleRate }` — calls `_initWasm()`
- `{ type: 'setParam', index, value }` — calls `_onSetParam()`
- `{ type: 'noteOn', freq, vel }` — calls `_onNoteOn()`
- `{ type: 'noteOff', freq }` — calls `_onNoteOff()`
- Replies `{ type: 'ready' }` or `{ type: 'error', message }` to main thread
Each concrete engine processor overrides `_initWasm()` and `_renderBlock()`.

View file

@ -0,0 +1,10 @@
// additive.dsp — placeholder additive oscillator bank
// 4 harmonics with configurable frequency and amplitude.
// This is a pipeline-proving stub; the full 48-param additive engine
// is implemented in meml-pj4.
import("stdfaust.lib");
freq = hslider("freq[unit:Hz]", 220, 20, 4000, 0.1);
amp = hslider("amp", 0.5, 0, 1, 0.001);
process = sum(i, 4, amp * (1.0/(i+1)) * os.osc(freq * (i+1))) <: _,_;

109
playground/faust/build.sh Executable file
View file

@ -0,0 +1,109 @@
#!/usr/bin/env bash
# build.sh — compile all Faust DSP files in this directory to WASM + JSON
#
# Required tools:
# faust >= 2.60.0 (https://faust.grame.fr / nix: faust)
# emcc >= 3.1.x (Emscripten, already present at /usr/lib/emscripten/emcc)
#
# Usage:
# cd playground/faust && ./build.sh
#
# Outputs (alongside each .dsp):
# <name>.wasm — compiled audio DSP binary
# <name>.json — Faust UI descriptor (consumed by faustJsonToParamMeta)
# <name>.js — JS glue / AudioWorklet wrapper generated by faust2wasm
#
# The JSON descriptor is the key artifact: it contains the full UI tree
# (hslider / vslider / nentry / groups) that faustJsonToParamMeta.js parses
# into the playground's standard paramMeta format.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# ---------------------------------------------------------------------------
# Dependency checks
# ---------------------------------------------------------------------------
if ! command -v faust &>/dev/null; then
echo ""
echo "ERROR: 'faust' not found in PATH."
echo ""
echo "Install options:"
echo " nix-shell -p faust # one-off"
echo " nix profile install nixpkgs#faust # permanent"
echo " Or download from: https://faust.grame.fr/downloads/"
echo ""
echo "Required version: >= 2.60.0"
echo "Check: faust --version"
exit 1
fi
FAUST_VER="$(faust --version 2>&1 | head -1)"
echo "faust: $FAUST_VER"
# emcc is optional — faust -lang wasm doesn't require it.
# It is needed if you want to link extra C++ into the WASM module.
if command -v emcc &>/dev/null; then
EMCC_VER="$(emcc --version 2>&1 | head -1)"
echo "emcc : $EMCC_VER"
else
echo "emcc : not found (not required for basic faust -lang wasm builds)"
fi
echo ""
# ---------------------------------------------------------------------------
# Compile each .dsp
# ---------------------------------------------------------------------------
DSP_FILES=("$SCRIPT_DIR"/*.dsp)
if [ ${#DSP_FILES[@]} -eq 0 ]; then
echo "No .dsp files found in $SCRIPT_DIR"
exit 0
fi
for DSP in "${DSP_FILES[@]}"; do
NAME="$(basename "$DSP" .dsp)"
echo "Compiling $NAME.dsp ..."
# Step 1: emit WASM binary + JS glue
# -lang wasm — target WebAssembly
# -cn <Name> — class name prefix in generated JS
# -e — export all DSP metadata into the WASM module
# -O <dir> — output directory
faust -lang wasm \
-cn "$NAME" \
-e \
-O "$SCRIPT_DIR" \
"$DSP" \
-o "$SCRIPT_DIR/${NAME}.wasm"
# Step 2: emit the standalone JSON descriptor (separate pass so the JSON
# is always present even if the WASM glue is regenerated).
# -json produces <dsp-file>.json next to the source.
faust -json "$DSP" -o /dev/null 2>/dev/null || \
faust -lang codebox -json "$DSP" -o /dev/null 2>/dev/null || true
# Faust places the JSON next to the .dsp; rename to sit next to outputs.
if [ -f "${DSP}.json" ]; then
mv "${DSP}.json" "$SCRIPT_DIR/${NAME}.json"
elif [ -f "$SCRIPT_DIR/${NAME}.json" ]; then
: # already in the right place (some faust versions)
else
echo " WARNING: ${NAME}.json not produced — check faust version"
fi
if [ -f "$SCRIPT_DIR/${NAME}.wasm" ]; then
WASM_SIZE="$(du -h "$SCRIPT_DIR/${NAME}.wasm" | cut -f1)"
echo " -> ${NAME}.wasm (${WASM_SIZE})"
fi
if [ -f "$SCRIPT_DIR/${NAME}.json" ]; then
echo " -> ${NAME}.json"
fi
done
echo ""
echo "Done. Load engines in the playground via FaustEngineBase:"
echo " import { AdditiveEngine } from './js/synth/additive-engine.js';"

View file

@ -0,0 +1,138 @@
/**
* faust-worklet-processor.js Base AudioWorklet processor for Faust WASM engines
*
* This file must be loaded via audioContext.audioWorklet.addModule() before
* creating a FaustWorkletNode. It runs in AudioWorkletGlobalScope.
*
* Each Faust engine subclasses FaustWorkletProcessor and overrides:
* - static get processorName() returns the unique processor name string
* - _initWasm(wasmBytes, sampleRate) initialises the Faust WASM instance
* - _renderBlock(outputL, outputR, blockSize) fills output buffers per block
*
* Message protocol (port.postMessage from main thread):
* { type: 'init', wasmBytes: ArrayBuffer, sampleRate: number }
* { type: 'setParam', index: number, value: number }
* { type: 'noteOn', freq: number, vel: number }
* { type: 'noteOff', freq: number }
*
* Replies from worklet to main thread:
* { type: 'ready' }
* { type: 'error', message: string }
*/
class FaustWorkletProcessor extends AudioWorkletProcessor {
constructor(options) {
super(options);
this._ready = false;
this._paramValues = {}; // index → current value (raw Faust units)
this.port.onmessage = (e) => this._handleMessage(e.data);
}
// ---------------------------------------------------------------------------
// Message handler (runs in worklet thread)
// ---------------------------------------------------------------------------
_handleMessage(msg) {
if (!msg || !msg.type) return;
switch (msg.type) {
case 'init':
this._initWasm(msg.wasmBytes, msg.sampleRate || sampleRate)
.then(() => {
this._ready = true;
this.port.postMessage({ type: 'ready' });
})
.catch((err) => {
this.port.postMessage({ type: 'error', message: String(err) });
});
break;
case 'setParam':
this._paramValues[msg.index] = msg.value;
this._onSetParam(msg.index, msg.value);
break;
case 'noteOn':
this._onNoteOn(msg.freq, msg.vel);
break;
case 'noteOff':
this._onNoteOff(msg.freq);
break;
default:
console.warn('[FaustWorkletProcessor] Unknown message type:', msg.type);
}
}
// ---------------------------------------------------------------------------
// AudioWorkletProcessor interface
// ---------------------------------------------------------------------------
process(_inputs, outputs, _params) {
if (!this._ready) return true;
const out = outputs[0];
const blockSize = out[0]?.length ?? 128;
const outL = out[0] ?? new Float32Array(blockSize);
const outR = out[1] ?? new Float32Array(blockSize);
this._renderBlock(outL, outR, blockSize);
return true; // keep processor alive
}
// ---------------------------------------------------------------------------
// Subclass API — override these in concrete engine processors
// ---------------------------------------------------------------------------
/**
* Initialise the WASM module. Called once with the raw bytes and sample rate.
* Must return a Promise that resolves when the engine is ready to render.
*
* @param {ArrayBuffer} wasmBytes
* @param {number} sampleRate
* @returns {Promise<void>}
*/
async _initWasm(_wasmBytes, _sampleRate) {
// Default no-op: subclasses that don't use WASM can override _renderBlock only.
}
/**
* Called when a parameter value changes. Override to forward to DSP.
* @param {number} index Param index (matches paramMeta order)
* @param {number} value Raw value in Faust units
*/
_onSetParam(_index, _value) {}
/**
* Called on note-on.
* @param {number} freq Hz
* @param {number} vel 01
*/
_onNoteOn(_freq, _vel) {}
/**
* Called on note-off.
* @param {number} freq Hz
*/
_onNoteOff(_freq) {}
/**
* Fill a single audio block. Called from process() every 128 samples.
* Both arrays are pre-allocated Float32Arrays of length blockSize.
*
* @param {Float32Array} outL Left channel output buffer (write to this)
* @param {Float32Array} outR Right channel output buffer (write to this)
* @param {number} blockSize
*/
_renderBlock(_outL, _outR, _blockSize) {
// Default: silence — override in subclass
}
}
// Note: registerProcessor() is called by each concrete engine file, not here,
// because each engine has its own processor name.
// Subclass files should end with:
// registerProcessor('my-engine-processor', MyEngineProcessor);

View file

@ -0,0 +1,13 @@
// fm-matrix.dsp — placeholder 2-op FM synthesizer
// Carrier modulated by a single operator.
// This is a pipeline-proving stub; the full 56-param FM matrix engine
// is implemented in meml-wgg.
import("stdfaust.lib");
freq = hslider("freq[unit:Hz]", 220, 20, 4000, 0.1);
ratio = hslider("ratio", 2.0, 0.125, 16.0, 0.001);
index = hslider("index", 1.0, 0.0, 10.0, 0.001);
amp = hslider("amp", 0.5, 0, 1, 0.001);
mod = amp * os.osc(freq * ratio);
process = amp * os.osc(freq + index * mod * freq) <: _,_;

View file

@ -0,0 +1,145 @@
// SynthEngine — base interface that all synth engines implement.
//
// Engines are duck-typed in JavaScript, but this base class documents the
// contract and provides default no-op stubs so subclasses only override what
// they need. All engines must satisfy this interface for hot-swapping to work.
//
// Usage:
// import { SynthEngine } from './engine-interface.js';
// class MyEngine extends SynthEngine { ... }
export class SynthEngine {
// ---------------------------------------------------------------------------
// Identity (override in subclass)
// ---------------------------------------------------------------------------
/**
* Stable machine ID, e.g. 'shaper-feedback' | 'additive' | 'fm'.
* Used for persistence keys and engine-switcher logic.
* @returns {string}
*/
get id() {
throw new Error(`${this.constructor.name}: id not implemented`);
}
/**
* Human-readable label shown in the engine-switcher UI.
* @returns {string}
*/
get displayName() {
throw new Error(`${this.constructor.name}: displayName not implemented`);
}
// ---------------------------------------------------------------------------
// Parameter schema
// ---------------------------------------------------------------------------
/**
* Number of continuous parameters this engine exposes (= MLP output count).
* @returns {number}
*/
get paramCount() {
return this.paramMeta.length;
}
/**
* Array of parameter descriptors, one per MLP output index.
*
* Each entry must have:
* id {string} stable machine ID (used for presets)
* name {string} short display name
* min {number} normalised lower bound [0,1]
* max {number} normalised upper bound [0,1]
* init {number} default normalised value [0,1]
* curve {number} power-curve bias: 0.5 = linear, <0.5 = log, >0.5 = exp
* group {string} section label (for group drawer / colour coding)
*
* @returns {Array<{id:string, name:string, min:number, max:number, init:number, curve:number, group:string}>}
*/
get paramMeta() {
throw new Error(`${this.constructor.name}: paramMeta not implemented`);
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/**
* Load WASM / worklet, connect the output node to audioCtx.destination.
* Must be idempotent calling init() twice should be safe.
*
* @param {AudioContext} audioCtx
* @returns {Promise<void>}
*/
async init(audioCtx) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: init() not implemented`);
}
/**
* Release all AudioNodes, Workers, and WASM memory.
* The engine should be unusable after dispose().
*/
dispose() {
// default no-op — override when cleanup is needed
}
// ---------------------------------------------------------------------------
// Real-time control
// ---------------------------------------------------------------------------
/**
* Set a single parameter by MLP output index.
*
* @param {number} index 0-based index into paramMeta
* @param {number} normalizedValue [0, 1]
*/
setParam(index, normalizedValue) { // eslint-disable-line no-unused-vars
// default no-op
}
/**
* Trigger a note.
*
* @param {number} note MIDI note number 0127
* @param {number} velocity [0, 1]
*/
noteOn(note, velocity = 0.7) { // eslint-disable-line no-unused-vars
// default no-op
}
/**
* Release a note.
*
* @param {number} note MIDI note number 0127
*/
noteOff(note) { // eslint-disable-line no-unused-vars
// default no-op
}
// ---------------------------------------------------------------------------
// Audio graph
// ---------------------------------------------------------------------------
/**
* Return the AudioNode that should be connected downstream (e.g. to a
* compressor or the AudioContext destination).
*
* @returns {AudioNode}
*/
getOutputNode() {
throw new Error(`${this.constructor.name}: getOutputNode() not implemented`);
}
// ---------------------------------------------------------------------------
// Status (mirrors C15Bridge.running for backward compat)
// ---------------------------------------------------------------------------
/**
* True once init() has completed successfully and the engine is producing
* audio. Engines should set this themselves after init().
* @type {boolean}
*/
get running() {
return this._running ?? false;
}
}

View file

@ -0,0 +1,211 @@
/**
* faust-engine-base.js Base class for Faust WASM synth engines
*
* Handles the common lifecycle for engines compiled from Faust .dsp files:
* 1. Fetch the .json descriptor build paramMeta via faustJsonToParamMeta()
* 2. Load the .wasm binary as an AudioWorkletNode
* 3. Wire setParam / noteOn / noteOff through worklet messages
*
* Usage:
* class AdditiveEngine extends FaustEngineBase {
* constructor() {
* super({
* id: 'additive',
* displayName: 'Additive',
* wasmUrl: 'faust/additive.wasm',
* jsonUrl: 'faust/additive.json',
* workletUrl: 'faust/additive-processor.js',
* processorName: 'additive-processor',
* });
* }
* }
*
* const engine = new AdditiveEngine();
* await engine.init(audioCtx);
* engine.noteOn(220, 0.8);
* engine.setParam(0, 0.5);
*/
import { SynthEngine } from './engine-interface.js';
import { loadFaustParamMeta } from './faust-param-meta.js';
export class FaustEngineBase extends SynthEngine {
/**
* @param {object} opts
* @param {string} opts.id Short unique id (e.g. 'additive')
* @param {string} opts.displayName Human-readable name
* @param {string} opts.wasmUrl URL to the .wasm binary (faust -lang wasm output)
* @param {string} opts.jsonUrl URL to the Faust .json descriptor
* @param {string} opts.workletUrl URL to the AudioWorklet processor JS file
* @param {string} opts.processorName Name passed to registerProcessor() in workletUrl
*/
constructor({ id, displayName, wasmUrl, jsonUrl, workletUrl, processorName } = {}) {
super();
this._id = id ?? 'faust-engine';
this._displayName = displayName ?? 'Faust Engine';
this._wasmUrl = wasmUrl ?? null;
this._jsonUrl = jsonUrl ?? null;
this._workletUrl = workletUrl ?? null;
this._processorName = processorName ?? null;
this._paramMeta = []; // populated in init()
this._audioCtx = null;
this._workletNode = null;
this._masterGain = null;
this._running = false;
this._onReady = null; // internal ready-wait callback
this._outputNode = null;
}
// ---------------------------------------------------------------------------
// SynthEngine identity
// ---------------------------------------------------------------------------
get id() { return this._id; }
get displayName() { return this._displayName; }
get paramMeta() { return this._paramMeta; }
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/**
* Initialise the engine: fetch JSON build paramMeta load WASM worklet.
*
* @param {AudioContext} audioCtx A running (or suspended) AudioContext.
* @returns {Promise<void>}
*/
async init(audioCtx) {
if (this._running) return;
this._audioCtx = audioCtx;
// Step 1: fetch and parse the Faust JSON descriptor → paramMeta
this._paramMeta = await loadFaustParamMeta(this._jsonUrl);
// Step 2: fetch the WASM binary
const wasmResp = await fetch(this._wasmUrl);
if (!wasmResp.ok) {
throw new Error(`[FaustEngineBase:${this._id}] Failed to fetch WASM: ${wasmResp.status}`);
}
const wasmBytes = await wasmResp.arrayBuffer();
// Step 3: register the AudioWorklet module (browser deduplicates)
await audioCtx.audioWorklet.addModule(this._workletUrl);
// Step 4: create the AudioWorkletNode
this._workletNode = new AudioWorkletNode(audioCtx, this._processorName, {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [2],
});
// Step 5: listen for worklet → main thread messages
this._workletNode.port.onmessage = (e) => this._handleWorkletMsg(e.data);
// Step 6: connect to audio graph through a master gain node
this._masterGain = audioCtx.createGain();
this._masterGain.gain.value = 0.7;
this._workletNode.connect(this._masterGain);
this._masterGain.connect(audioCtx.destination);
this._outputNode = this._masterGain;
// Step 7: send init message — transfer ownership of wasmBytes to avoid copy
this._workletNode.port.postMessage(
{ type: 'init', wasmBytes, sampleRate: audioCtx.sampleRate },
[wasmBytes]
);
// Wait for worklet to confirm readiness (10 s timeout)
await this._waitForReady(10_000);
}
/** Return the output AudioNode (for downstream routing). */
getOutputNode() {
return this._outputNode;
}
/** Release all resources. */
dispose() {
if (this._masterGain) {
this._masterGain.disconnect();
this._masterGain = null;
}
if (this._workletNode) {
this._workletNode.disconnect();
this._workletNode.port.onmessage = null;
this._workletNode = null;
}
this._outputNode = null;
this._running = false;
}
// ---------------------------------------------------------------------------
// Real-time control
// ---------------------------------------------------------------------------
/**
* Set a parameter by index.
* normalizedValue is [0, 1] and is mapped to the param's [min, max] range.
*
* @param {number} index Index into paramMeta
* @param {number} normalizedValue 01
*/
setParam(index, normalizedValue) {
if (!this._workletNode) return;
const meta = this._paramMeta[index];
if (!meta) return;
const raw = meta.min + normalizedValue * (meta.max - meta.min);
this._workletNode.port.postMessage({ type: 'setParam', index, value: raw });
}
/**
* Trigger a note.
* @param {number} note MIDI note number 0127 (converts to Hz internally)
* @param {number} vel Velocity 01 (default 0.7)
*/
noteOn(note, vel = 0.7) {
if (!this._workletNode) return;
const freq = 440 * Math.pow(2, (note - 69) / 12);
this._workletNode.port.postMessage({ type: 'noteOn', freq, vel });
}
/**
* Release a note.
* @param {number} note MIDI note number 0127
*/
noteOff(note) {
if (!this._workletNode) return;
const freq = 440 * Math.pow(2, (note - 69) / 12);
this._workletNode.port.postMessage({ type: 'noteOff', freq });
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
_handleWorkletMsg(data) {
if (!data) return;
if (data.type === 'ready') {
this._running = true;
this._onReady?.();
} else if (data.type === 'error') {
console.error(`[FaustEngineBase:${this._id}] Worklet error:`, data.message);
}
}
_waitForReady(timeoutMs) {
if (this._running) return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this._onReady = null;
reject(new Error(`[FaustEngineBase:${this._id}] Timed out waiting for worklet ready`));
}, timeoutMs);
this._onReady = () => {
clearTimeout(timer);
this._onReady = null;
resolve();
};
});
}
}

View file

@ -0,0 +1,151 @@
/**
* faust-param-meta.js Faust JSON descriptor playground paramMeta converter
*
* Faust's -json flag (or the JSON embedded in faust2wasm glue) emits a UI
* descriptor tree. This module parses that tree into the standard paramMeta
* format used throughout the playground:
*
* [{id, name, min, max, init, curve, group}]
*
* Usage:
* import { faustJsonToParamMeta } from './faust-param-meta.js';
* const resp = await fetch('faust/additive.json');
* const faustJson = await resp.json();
* const paramMeta = faustJsonToParamMeta(faustJson);
*
* The returned array is ready to pass to SynthEngine._paramMeta.
*/
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Convert a Faust address path (e.g. "/additive/freq") to a clean id string.
* Strips the leading slash + top-level DSP name segment, replaces remaining
* slashes and spaces with underscores, lower-cases.
*
* Examples:
* "/additive/freq" "freq"
* "/fm-matrix/Osc/ratio" "osc_ratio"
* "/MyDSP/Effects/wet mix" "effects_wet_mix"
*
* @param {string} address Faust param address (e.g. from item.address)
* @returns {string}
*/
export function faustParamAddress(address) {
if (!address) return '';
// Remove leading slash
let s = address.replace(/^\//, '');
// Drop the first path segment (top-level DSP class name)
const slashIdx = s.indexOf('/');
if (slashIdx !== -1) s = s.slice(slashIdx + 1);
// Normalise: lowercase, spaces → underscores, slashes → underscores
return s.toLowerCase().replace(/[\s/]+/g, '_');
}
/**
* Types of Faust UI items that represent continuous parameters.
* Buttons and checkboxes are intentionally excluded they are not suitable
* for ML-driven continuous control.
*/
const CONTINUOUS_TYPES = new Set(['hslider', 'vslider', 'nentry']);
/**
* Recursively walk a Faust UI tree and collect leaf parameter items.
*
* The Faust UI tree looks like:
* [
* {
* type: "vgroup",
* label: "Oscillator",
* items: [
* { type: "hslider", label: "freq", address: "/MyDSP/Oscillator/freq",
* min: 20, max: 4000, init: 220, step: 0.1, meta: [...] },
* ...
* ]
* },
* { type: "hslider", label: "amp", address: "/MyDSP/amp", ... }
* ]
*
* @param {Array} items Array of UI items at the current tree level
* @param {string} groupLabel Label of the nearest enclosing named group ("" at root)
* @param {Array} out Accumulator push {item, group} objects here
*/
export function parseFaustUiTree(items, groupLabel = '', out = []) {
if (!Array.isArray(items)) return out;
for (const item of items) {
if (!item || typeof item !== 'object') continue;
if (CONTINUOUS_TYPES.has(item.type)) {
// Leaf param — collect it, tagged with the nearest enclosing group
out.push({ item, group: groupLabel });
} else if (item.items) {
// Container node (vgroup, hgroup, tgroup) — recurse, propagating label
const childGroup = item.label || groupLabel;
parseFaustUiTree(item.items, childGroup, out);
}
// Buttons, checkboxes, bargraphs — ignored (non-continuous)
}
return out;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Convert a Faust JSON descriptor to a paramMeta array.
*
* @param {object} faustJson Parsed JSON from `faust -json` or embedded in WASM glue.
* Expected shape: { name, ui: [...], ...meta }
* @returns {Array<{id: string, name: string, min: number, max: number,
* init: number, curve: number, group: string}>}
*
* Each element:
* id slug derived from the Faust address (unique within this engine)
* name human-readable label (the Faust hslider label, possibly with metadata stripped)
* min minimum value (raw Faust units)
* max maximum value (raw Faust units)
* init default value
* curve 0.5 (linear default; override per-engine if needed)
* group nearest enclosing group label (empty string for top-level params)
*/
export function faustJsonToParamMeta(faustJson) {
if (!faustJson || !Array.isArray(faustJson.ui)) {
console.warn('[faustJsonToParamMeta] Invalid or empty Faust JSON — no ui array found');
return [];
}
const collected = parseFaustUiTree(faustJson.ui);
return collected.map(({ item, group }) => {
// Strip Faust metadata annotations from label: "freq[unit:Hz]" → "freq"
const cleanLabel = (item.label || '').replace(/\[.*?\]/g, '').trim();
return {
id: faustParamAddress(item.address),
name: cleanLabel,
min: typeof item.min === 'number' ? item.min : 0,
max: typeof item.max === 'number' ? item.max : 1,
init: typeof item.init === 'number' ? item.init : 0,
curve: 0.5, // linear — override per-engine if needed
group: group || '',
};
});
}
/**
* Convenience: fetch a Faust JSON file by URL and return paramMeta.
*
* @param {string} jsonUrl URL of the .json descriptor (e.g. 'faust/additive.json')
* @returns {Promise<Array>}
*/
export async function loadFaustParamMeta(jsonUrl) {
const resp = await fetch(jsonUrl);
if (!resp.ok) throw new Error(`[faustJsonToParamMeta] Failed to fetch ${jsonUrl}: ${resp.status}`);
const json = await resp.json();
return faustJsonToParamMeta(json);
}