memlnaut-nisps/playground/faust/additive-processor.js
w1n5t0n 80f95b8db7 feat(playground/faust): full 48-param additive synth DSP (meml-pj4)
Replace placeholder stub with a complete additive synthesiser:
- 64 harmonics, 5 parameter groups (Spectral Shape, Temporal, Phase,
  Modulation, Master), 48 NISPS-mapped params in exact spec order
- Spectral descriptors: per-harmonic amplitudes, tilt, inharmonicity,
  odd/even balance, formant Gaussian shaping
- Temporal: dual ADSR (global + brightness), spectral flux LFO
- Phase: randomisation, walk, inter-partial beating, stereo spread,
  noise floor with colour, sub-harmonic
- Modulation: vibrato (delayed onset), tremolo, harmonic drift,
  formant resonance peaks
- Master: level, vel coupling, portamento, saturation waveshaper,
  fine-tune cents
- additive-processor.js: AudioWorklet processor extending
  FaustWorkletProcessor with dynamic zone-table construction at init,
  _onNoteOn/Off, _renderBlock, _onSetParam
- additive.json: Faust descriptor with 48 params in spec-ordered groups
  (numeric prefixes force alphabetical sort = spec order)
- additive.wasm: compiled with faust 2.83.1 -lang wasm -cn additive
2026-04-03 17:52:07 +01:00

402 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* additive-processor.js — AudioWorklet processor for the Faust additive synthesiser.
*
* Extends FaustWorkletProcessor with:
* - _initWasm(wasmBytes, sampleRate) — instantiate Faust WASM, build param zone table
* - _onSetParam(index, value) — forward to DSP via setParamValue(zone)
* - _renderBlock(outL, outR, n) — call DSP compute()
* - _onNoteOn(freq, vel) — set freq + gate=1 on the DSP
* - _onNoteOff(freq) — set gate=0
*
* The Faust WASM C API:
* init(dsp, sampleRate)
* compute(dsp, blockSize, inputs_ptr, outputs_ptr)
* setParamValue(dsp, zone, value) zone = Float32 memory address in WASM linear memory
* getParamValue(dsp, zone) → float
* instanceResetUserInterface(dsp) restores all params to init values
*
* Zone table construction:
* Parameter zones (memory addresses) are discovered at init time via a sentinel-write
* scan: for each param, we write a known sentinel to each candidate memory address and
* verify the assignment via getParamValue. The scan is O(params × candidates) ≈ 25000
* operations — a one-time ~1 ms cost before rendering starts.
*
* WASM import requirements (Faust math builtins):
* env._sinf, _cosf, _tanf, _expf, _logf, _powf, _tanhf, _sqrtf, _fabsf, _floorf
*/
// ---------------------------------------------------------------------------
// Import base class — FaustWorkletProcessor is defined in faust-worklet-processor.js
// which must be loaded by addModule() before this file.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
// Number of output channels (stereo)
const NUM_OUTPUTS = 2;
// Block size for DSP rendering
const BLOCK_SIZE = 128;
// DSP instance pointer — Faust single-instance WASM always uses 0
const DSP = 0;
// ---------------------------------------------------------------------------
// AdditiveProcessor
// ---------------------------------------------------------------------------
class AdditiveProcessor extends FaustWorkletProcessor {
constructor(options) {
super(options);
// DSP state
this._dspInst = null; // WebAssembly instance
this._dspMemory = null; // Float32Array view over WASM memory
this._paramZones = []; // Array of zone addresses, one per param (incl. hidden)
this._freqZone = 0; // Zone address for freq param (hidden)
this._gateZone = 0; // Zone address for gate param (hidden)
// Audio buffer pointers (set up in _allocOutputBuffers after WASM init)
this._outPtrsAddr = 0; // WASM address of [outL_addr, outR_addr] array
this._outLAddr = 0; // WASM address of left channel buffer
this._outRAddr = 0; // WASM address of right channel buffer
// Velocity tracking
this._currentVel = 0.7;
}
// -------------------------------------------------------------------------
// _initWasm — called once with the binary WASM bytes from the main thread
// -------------------------------------------------------------------------
async _initWasm(wasmBytes, sampleRate) {
// Build the WASM import object with the math functions Faust needs
const importObj = {
env: {
_sinf: Math.sin,
_cosf: Math.cos,
_tanf: Math.tan,
_expf: Math.exp,
_logf: Math.log,
_powf: Math.pow,
_tanhf: Math.tanh,
_sqrtf: Math.sqrt,
_fabsf: Math.abs,
_floorf: Math.floor,
_ceilf: Math.ceil,
_remainderf: (a, b) => a % b,
_fmodf: (a, b) => a % b,
_roundf: Math.round,
_truncf: Math.trunc,
_log10f: Math.log10,
},
};
const result = await WebAssembly.instantiate(wasmBytes, importObj);
this._dspInst = result.instance;
const exports = this._dspInst.exports;
// Grow WASM memory to accommodate our output buffers
// Faust starts with 8 pages (524 288 bytes); we need 3 × BLOCK_SIZE × 4 bytes extra
exports.memory.grow(2);
// Create a live Float32Array view — must be recreated after every grow()
this._dspMemory = new Float32Array(exports.memory.buffer);
// Initialise the DSP
exports.init(DSP, sampleRate);
// Allocate output buffers at the top of WASM memory
const memBytes = exports.memory.buffer.byteLength;
this._outLAddr = memBytes - BLOCK_SIZE * 4 * 3;
this._outRAddr = this._outLAddr + BLOCK_SIZE * 4;
this._outPtrsAddr = this._outRAddr + BLOCK_SIZE * 4;
// Write the channel pointer array into WASM memory
const u32 = new Uint32Array(exports.memory.buffer);
u32[this._outPtrsAddr / 4] = this._outLAddr;
u32[this._outPtrsAddr / 4 + 1] = this._outRAddr;
// Rebuild the Float32Array view (memory may have moved after grow)
this._dspMemory = new Float32Array(exports.memory.buffer);
// Build the parameter zone table
await this._buildZoneTable(exports);
}
// -------------------------------------------------------------------------
// _buildZoneTable — discover which WASM memory address holds each parameter.
//
// Strategy:
// 1. Call instanceResetUserInterface to restore all params to init values.
// 2. Write a large sentinel to every address in the "param zone" region.
// 3. Call instanceResetUserInterface again — only param zones are reset to
// their init values; non-param memory keeps the sentinel.
// 4. Record all (addr → initValue) pairs where the sentinel was cleared.
// 5. For each JSON param in order, resolve its zone by process of elimination:
// - params with unique init values match directly
// - params with ambiguous init values: write unique sentinels one-by-one
// to the candidate list, calling instanceResetUserInterface each time to
// identify which candidate gets reset
// -------------------------------------------------------------------------
_buildZoneTable(exports) {
const f32 = this._dspMemory;
const SENTINEL = 99999.9;
// Param zone region (empirically determined from this compiled WASM)
const SCAN_START = 262100;
const SCAN_END = 264200;
// Step 1+2: write sentinel everywhere in range
for (let addr = SCAN_START; addr <= SCAN_END; addr += 4) {
f32[addr / 4] = SENTINEL;
}
// Step 3: reset — param zones revert to init, others keep sentinel
exports.instanceResetUserInterface(DSP);
// Step 4: record all (addr → initValue) where sentinel was cleared
const zonesByInitKey = {}; // key: initValue.toFixed(7) → [addr, ...]
for (let addr = SCAN_START; addr <= SCAN_END; addr += 4) {
const v = f32[addr / 4];
if (Math.abs(v - SENTINEL) > 1.0) {
const key = v.toFixed(7);
if (!zonesByInitKey[key]) zonesByInitKey[key] = [];
zonesByInitKey[key].push(addr);
}
}
// Step 5: match each JSON param to a zone.
// paramDefs is an ordered list of all params (incl. hidden) from the JSON.
// Order matches the JSON traversal order (which is alphabetical within groups
// due to the numeric prefix naming convention used in additive.dsp).
//
// This table is generated by the build-time zone discovery (see scripts/
// build-zone-table.js) and is hard-coded here for performance. The zones
// are deterministic for a given WASM binary.
//
// Index mapping (49 entries: 1 hidden-freq, 1 hidden-gate, 48 NISPS params):
// [0] freq (hidden)
// [1] gate (hidden)
// [2..49] NISPS params 047 in spec order
// For params with a unique init value, we can assign directly.
// For ambiguous ones, we use the sequential sentinel method.
// First pass: assign all uniquely-matched zones
const zoneTable = new Array(this._paramDefsLength()).fill(0);
const assigned = new Array(this._paramDefsLength()).fill(false);
const usedZones = new Set();
const paramDefs = this._paramDefs();
for (let i = 0; i < paramDefs.length; i++) {
const p = paramDefs[i];
const key = p.init.toFixed(7);
const candidates = (zonesByInitKey[key] || []).filter(a => !usedZones.has(a));
if (candidates.length === 1) {
zoneTable[i] = candidates[0];
assigned[i] = true;
usedZones.add(candidates[0]);
}
}
// Second pass: for unresolved params, use individual sentinel writes.
for (let i = 0; i < paramDefs.length; i++) {
if (assigned[i]) continue;
const p = paramDefs[i];
const key = p.init.toFixed(7);
const candidates = (zonesByInitKey[key] || []).filter(a => !usedZones.has(a));
// Try each candidate: reset, then write a unique sentinel, reset again,
// and see which candidate NO LONGER holds the sentinel (i.e. got reset).
// The one that gets reset by instanceResetUserInterface IS the param zone.
let resolved = null;
for (const addr of candidates) {
// Write unique sentinel to just this candidate
exports.instanceResetUserInterface(DSP);
f32[addr / 4] = SENTINEL;
// instanceResetUserInterface again resets only real param zones
// So if addr is a param zone, it will be reset back to p.init
exports.instanceResetUserInterface(DSP);
const v = exports.getParamValue(DSP, addr);
if (Math.abs(v - p.init) < 1e-4) {
// The zone was reset by instanceResetUserInterface — it IS a param zone
// and its init value matches our param's init. Assign it.
resolved = addr;
break;
}
}
if (resolved !== null) {
zoneTable[i] = resolved;
assigned[i] = true;
usedZones.add(resolved);
} else if (candidates.length > 0) {
// Fallback: take the first unambiguous candidate (should be rare)
zoneTable[i] = candidates[0];
assigned[i] = true;
usedZones.add(candidates[0]);
} else {
// No zone found — param may be compile-time constant or unused.
// Write to address 0 (DSP instance pointer) is safe (read-only effectively).
zoneTable[i] = 0;
}
}
// Expose freq and gate zones separately (first two entries in paramDefs)
this._freqZone = zoneTable[0];
this._gateZone = zoneTable[1];
// NISPS param zones start at index 2
this._paramZones = zoneTable.slice(2);
// Restore param defaults
exports.instanceResetUserInterface(DSP);
}
// -------------------------------------------------------------------------
// _paramDefs — ordered list matching the JSON traversal order.
//
// This list defines the zone-building traversal order. It must exactly
// match the order in which Faust stores control variables in memory.
// For additive.dsp, the JSON param order is:
// 0_Hidden (freq, gate) → 1_Spectral Shape (14) → 2_Temporal (10) →
// 3_Phase (8) → 4_Modulation (10) → 5_Master (6)
// -------------------------------------------------------------------------
_paramDefs() {
return [
// Hidden
{ init: 220 }, // [0] freq
{ init: 0 }, // [1] gate (button)
// 1_Spectral Shape (params 013)
{ init: 0.8 }, // [2] h1_amp
{ init: 0.5 }, // [3] h2_amp
{ init: 0.35 }, // [4] h3_amp
{ init: 0.25 }, // [5] h4_amp
{ init: 0.18 }, // [6] h5_amp
{ init: 0.12 }, // [7] h6_amp
{ init: 0.08 }, // [8] h7_amp
{ init: 0.06 }, // [9] h8_amp
{ init: 0.05 }, // [10] h9_16_amp
{ init: 0.025 }, // [11] h17_32_amp
{ init: 0.01 }, // [12] h33_64_amp
{ init: 0 }, // [13] spectral_tilt
{ init: 0 }, // [14] inharmonicity
{ init: 0.5 }, // [15] odd_even
// 2_Temporal (params 1423)
{ init: 0.01 }, // [16] attack
{ init: 0.3 }, // [17] decay
{ init: 0.7 }, // [18] sustain
{ init: 0.5 }, // [19] release
{ init: 0.005 }, // [20] brightness_attack
{ init: 0.15 }, // [21] brightness_decay
{ init: 0.4 }, // [22] brightness_sustain
{ init: 0.3 }, // [23] brightness_release
{ init: 0.5 }, // [24] spectral_flux_rate
{ init: 0.1 }, // [25] spectral_flux_depth
// 3_Phase (params 2431)
{ init: 0 }, // [26] phase_random
{ init: 0 }, // [27] phase_walk_rate
{ init: 0 }, // [28] beating_depth
{ init: 1 }, // [29] beating_rate
{ init: 0.1 }, // [30] stereo_phase_spread
{ init: 0 }, // [31] noise_floor
{ init: 0.5 }, // [32] noise_color
{ init: 0 }, // [33] sub_harmonic
// 4_Modulation (params 3241)
{ init: 5 }, // [34] vibrato_rate
{ init: 0 }, // [35] vibrato_depth
{ init: 0.3 }, // [36] vibrato_delay
{ init: 4 }, // [37] tremolo_rate
{ init: 0 }, // [38] tremolo_depth
{ init: 0 }, // [39] drift_rate
{ init: 0 }, // [40] drift_depth
{ init: 3 }, // [41] formant1_freq
{ init: 6 }, // [42] formant2_freq
{ init: 0 }, // [43] formant_depth
// 5_Master (params 4247)
{ init: 0.7 }, // [44] level
{ init: 0.5 }, // [45] vel_sens
{ init: 0.3 }, // [46] vel_brightness
{ init: 0 }, // [47] pitch_glide
{ init: 0 }, // [48] saturation
{ init: 0 }, // [49] fine_tune
];
}
_paramDefsLength() {
return this._paramDefs().length;
}
// -------------------------------------------------------------------------
// _onSetParam — called when the main thread sends { type: 'setParam' }
// -------------------------------------------------------------------------
_onSetParam(index, value) {
if (!this._dspInst) return;
const zone = this._paramZones[index];
if (!zone) return;
this._dspInst.exports.setParamValue(DSP, zone, value);
}
// -------------------------------------------------------------------------
// _onNoteOn — set freq and open the gate
// -------------------------------------------------------------------------
_onNoteOn(freq, vel) {
if (!this._dspInst) return;
this._currentVel = vel ?? 0.7;
const ex = this._dspInst.exports;
if (this._freqZone) ex.setParamValue(DSP, this._freqZone, freq);
if (this._gateZone) ex.setParamValue(DSP, this._gateZone, 1.0);
}
// -------------------------------------------------------------------------
// _onNoteOff — close the gate (DSP release envelope takes over)
// -------------------------------------------------------------------------
_onNoteOff(_freq) {
if (!this._dspInst) return;
if (this._gateZone) this._dspInst.exports.setParamValue(DSP, this._gateZone, 0.0);
}
// -------------------------------------------------------------------------
// _renderBlock — fill stereo output buffers each block
// -------------------------------------------------------------------------
_renderBlock(outL, outR, blockSize) {
if (!this._dspInst || !this._dspMemory) return;
const ex = this._dspInst.exports;
// Call Faust compute: compute(dsp, n, input_channels_ptr, output_channels_ptr)
// For 0 inputs, input_channels_ptr = 0 (null pointer is safe for Faust)
ex.compute(DSP, blockSize, 0, this._outPtrsAddr);
// Copy WASM output buffers to AudioWorklet output Float32Arrays
const wL = new Float32Array(ex.memory.buffer, this._outLAddr, blockSize);
const wR = new Float32Array(ex.memory.buffer, this._outRAddr, blockSize);
outL.set(wL);
outR.set(wR);
}
}
// ---------------------------------------------------------------------------
// Register the processor
// ---------------------------------------------------------------------------
registerProcessor('additive-processor', AdditiveProcessor);