diff --git a/playground/faust/README.md b/playground/faust/README.md new file mode 100644 index 0000000..ae8753d --- /dev/null +++ b/playground/faust/README.md @@ -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: + +- `.wasm` — the compiled DSP binary (loaded as an AudioWorkletNode) +- `.json` — the Faust UI descriptor (consumed by `faustJsonToParamMeta`) +- `.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()`. diff --git a/playground/faust/additive.dsp b/playground/faust/additive.dsp new file mode 100644 index 0000000..38f5b13 --- /dev/null +++ b/playground/faust/additive.dsp @@ -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))) <: _,_; diff --git a/playground/faust/build.sh b/playground/faust/build.sh new file mode 100755 index 0000000..fd3a308 --- /dev/null +++ b/playground/faust/build.sh @@ -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): +# .wasm — compiled audio DSP binary +# .json — Faust UI descriptor (consumed by faustJsonToParamMeta) +# .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 — class name prefix in generated JS + # -e — export all DSP metadata into the WASM module + # -O — 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 .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';" diff --git a/playground/faust/faust-worklet-processor.js b/playground/faust/faust-worklet-processor.js new file mode 100644 index 0000000..493ec39 --- /dev/null +++ b/playground/faust/faust-worklet-processor.js @@ -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} + */ + 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); diff --git a/playground/faust/fm-matrix.dsp b/playground/faust/fm-matrix.dsp new file mode 100644 index 0000000..74ca51d --- /dev/null +++ b/playground/faust/fm-matrix.dsp @@ -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) <: _,_; diff --git a/playground/js/synth/engine-interface.js b/playground/js/synth/engine-interface.js new file mode 100644 index 0000000..728b6d6 --- /dev/null +++ b/playground/js/synth/engine-interface.js @@ -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} + */ + 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; + } +} diff --git a/playground/js/synth/faust-engine-base.js b/playground/js/synth/faust-engine-base.js new file mode 100644 index 0000000..76d4ed9 --- /dev/null +++ b/playground/js/synth/faust-engine-base.js @@ -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} + */ + 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(); + }; + }); + } +} diff --git a/playground/js/synth/faust-param-meta.js b/playground/js/synth/faust-param-meta.js new file mode 100644 index 0000000..73d6239 --- /dev/null +++ b/playground/js/synth/faust-param-meta.js @@ -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} + */ +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); +}