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:
parent
c69b46f8cc
commit
55bf1b5d48
8 changed files with 931 additions and 0 deletions
154
playground/faust/README.md
Normal file
154
playground/faust/README.md
Normal 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()`.
|
||||||
10
playground/faust/additive.dsp
Normal file
10
playground/faust/additive.dsp
Normal 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
109
playground/faust/build.sh
Executable 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';"
|
||||||
138
playground/faust/faust-worklet-processor.js
Normal file
138
playground/faust/faust-worklet-processor.js
Normal 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 0–1
|
||||||
|
*/
|
||||||
|
_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);
|
||||||
13
playground/faust/fm-matrix.dsp
Normal file
13
playground/faust/fm-matrix.dsp
Normal 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) <: _,_;
|
||||||
145
playground/js/synth/engine-interface.js
Normal file
145
playground/js/synth/engine-interface.js
Normal 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 0–127
|
||||||
|
* @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 0–127
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
211
playground/js/synth/faust-engine-base.js
Normal file
211
playground/js/synth/faust-engine-base.js
Normal 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 0–1
|
||||||
|
*/
|
||||||
|
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 0–127 (converts to Hz internally)
|
||||||
|
* @param {number} vel Velocity 0–1 (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 0–127
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
151
playground/js/synth/faust-param-meta.js
Normal file
151
playground/js/synth/faust-param-meta.js
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue