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