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
402 lines
16 KiB
JavaScript
402 lines
16 KiB
JavaScript
/**
|
||
* 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 0–47 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 0–13)
|
||
{ 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 14–23)
|
||
{ 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 24–31)
|
||
{ 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 32–41)
|
||
{ 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 42–47)
|
||
{ 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);
|