feat(playground/faust): full 56-param FM matrix synth DSP (meml-wgg)
- Full 4-operator FM with continuous 4×4 routing matrix (12 cross-mod pairs) - Compiles to 46KB WASM via faust -lang wasm - 55 visible NISPS params (pitch_glide missing from DSP, tracked) - Includes fm-matrix-processor.js AudioWorklet + JSON param descriptor
This commit is contained in:
parent
e28c76535b
commit
67d2230ba8
4 changed files with 523 additions and 10 deletions
193
playground/faust/fm-matrix-processor.js
Normal file
193
playground/faust/fm-matrix-processor.js
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
/**
|
||||||
|
* fm-matrix-processor.js — AudioWorklet processor for the FM Matrix synth engine
|
||||||
|
*
|
||||||
|
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||||
|
* Loads fm-matrix.wasm compiled from fm-matrix.dsp.
|
||||||
|
*
|
||||||
|
* Parameter addresses are built from the Faust JSON descriptor at init time.
|
||||||
|
* Index→address mapping follows the order returned by faustJsonToParamMeta()
|
||||||
|
* (alphabetical within groups, groups in declaration order).
|
||||||
|
*
|
||||||
|
* Hidden params (not in NISPS 55-param list):
|
||||||
|
* /fm-matrix/Master/freq — set by noteOn
|
||||||
|
* /fm-matrix/Master/gate — set by noteOn/noteOff
|
||||||
|
* /fm-matrix/Master/_vel — set by noteOn
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Must be imported in AudioWorkletGlobalScope — include faust-worklet-processor.js
|
||||||
|
// via audioCtx.audioWorklet.addModule() before this file.
|
||||||
|
|
||||||
|
class FMMatrixProcessor extends FaustWorkletProcessor {
|
||||||
|
constructor(options) {
|
||||||
|
super(options);
|
||||||
|
this._dsp = null;
|
||||||
|
this._paramAddresses = []; // ordered by NISPS index (from JSON)
|
||||||
|
this._hiddenAddresses = {}; // freq, gate, _vel
|
||||||
|
this._blockSize = 128;
|
||||||
|
this._sampleRate = 48000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FaustWorkletProcessor overrides
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async _initWasm(wasmBytes, sr) {
|
||||||
|
this._sampleRate = sr;
|
||||||
|
|
||||||
|
// The Faust -lang wasm output exports a single factory function
|
||||||
|
// named by the -cn flag (fm_matrix).
|
||||||
|
const module = await WebAssembly.compile(wasmBytes);
|
||||||
|
const memory = new WebAssembly.Memory({ initial: 32, maximum: 256 });
|
||||||
|
|
||||||
|
const imports = {
|
||||||
|
env: {
|
||||||
|
memory,
|
||||||
|
memoryBase: 0,
|
||||||
|
tableBase: 0,
|
||||||
|
_abs: Math.abs,
|
||||||
|
_acosf: Math.acos,
|
||||||
|
_asinf: Math.asin,
|
||||||
|
_atanf: Math.atan,
|
||||||
|
_atan2f: Math.atan2,
|
||||||
|
_ceilf: Math.ceil,
|
||||||
|
_cosf: Math.cos,
|
||||||
|
_expf: Math.exp,
|
||||||
|
_floorf: Math.floor,
|
||||||
|
_fmodf: (x, y) => x % y,
|
||||||
|
_logf: Math.log,
|
||||||
|
_log10f: Math.log10,
|
||||||
|
_max_f: Math.max,
|
||||||
|
_min_f: Math.min,
|
||||||
|
_remainderf: (x, y) => x - Math.round(x / y) * y,
|
||||||
|
_powf: Math.pow,
|
||||||
|
_roundf: Math.round,
|
||||||
|
_sinf: Math.sin,
|
||||||
|
_sqrtf: Math.sqrt,
|
||||||
|
_tanf: Math.tan,
|
||||||
|
_fabs: Math.abs,
|
||||||
|
table: new WebAssembly.Table({ initial: 0, element: 'anyfunc' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const instance = await WebAssembly.instantiate(module, imports);
|
||||||
|
const exports = instance.exports;
|
||||||
|
|
||||||
|
// Faust -lang wasm exports: getNumInputs, getNumOutputs, init,
|
||||||
|
// instanceInit, getSampleRate, compute, setParamValue, getParamValue,
|
||||||
|
// getJSON (pointer to null-terminated JSON string in memory)
|
||||||
|
this._exports = exports;
|
||||||
|
this._heap = new Float32Array(memory.buffer);
|
||||||
|
this._heapi32 = new Int32Array(memory.buffer);
|
||||||
|
this._mem = memory;
|
||||||
|
|
||||||
|
// Allocate DSP instance (Faust C++ new equivalent)
|
||||||
|
if (exports.createDSPInstance) {
|
||||||
|
this._dsp = exports.createDSPInstance();
|
||||||
|
} else if (exports.fm_matrix) {
|
||||||
|
this._dsp = exports.fm_matrix();
|
||||||
|
} else {
|
||||||
|
// fallback: look for any exported constructor-like function
|
||||||
|
const keys = Object.keys(exports).filter(k => typeof exports[k] === 'function');
|
||||||
|
console.warn('[FMMatrixProcessor] No createDSPInstance found; exports:', keys);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise at sample rate
|
||||||
|
exports.init(this._dsp, sr);
|
||||||
|
|
||||||
|
// Parse embedded JSON to build param address list
|
||||||
|
this._buildParamIndex(exports, memory);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildParamIndex(exports, memory) {
|
||||||
|
// getJSON() returns a pointer to a JSON string in WASM memory
|
||||||
|
if (!exports.getJSON) return;
|
||||||
|
const ptr = exports.getJSON(this._dsp);
|
||||||
|
const buf = new Uint8Array(memory.buffer);
|
||||||
|
let str = '';
|
||||||
|
let i = ptr;
|
||||||
|
while (buf[i] !== 0) { str += String.fromCharCode(buf[i++]); }
|
||||||
|
|
||||||
|
let desc;
|
||||||
|
try { desc = JSON.parse(str); } catch { return; }
|
||||||
|
|
||||||
|
const HIDDEN = new Set(['freq', 'gate', '_vel']);
|
||||||
|
const addresses = [];
|
||||||
|
|
||||||
|
function walk(items, path) {
|
||||||
|
for (const item of items) {
|
||||||
|
const label = item.label ?? '';
|
||||||
|
const type = item.type ?? '';
|
||||||
|
const addr = item.address ?? (path + '/' + label);
|
||||||
|
if (['hslider', 'vslider', 'nentry', 'button', 'checkbox'].includes(type)) {
|
||||||
|
if (HIDDEN.has(label)) {
|
||||||
|
// store hidden separately
|
||||||
|
addresses._hidden = addresses._hidden || {};
|
||||||
|
addresses._hidden[label] = addr;
|
||||||
|
} else {
|
||||||
|
addresses.push(addr);
|
||||||
|
}
|
||||||
|
} else if (item.items) {
|
||||||
|
walk(item.items, path + '/' + label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(desc.ui ?? [], '');
|
||||||
|
this._paramAddresses = addresses;
|
||||||
|
this._hiddenAddresses = addresses._hidden ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
_onSetParam(index, value) {
|
||||||
|
if (!this._exports || !this._dsp) return;
|
||||||
|
const addr = this._paramAddresses[index];
|
||||||
|
if (addr && this._exports.setParamValue) {
|
||||||
|
this._exports.setParamValue(this._dsp, addr, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onNoteOn(freq, vel) {
|
||||||
|
if (!this._exports || !this._dsp) return;
|
||||||
|
const set = (label, v) => {
|
||||||
|
const addr = this._hiddenAddresses[label];
|
||||||
|
if (addr) this._exports.setParamValue?.(this._dsp, addr, v);
|
||||||
|
};
|
||||||
|
set('freq', freq);
|
||||||
|
set('_vel', vel);
|
||||||
|
set('gate', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onNoteOff(_freq) {
|
||||||
|
if (!this._exports || !this._dsp) return;
|
||||||
|
const addr = this._hiddenAddresses['gate'];
|
||||||
|
if (addr) this._exports.setParamValue?.(this._dsp, addr, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderBlock(outL, outR, blockSize) {
|
||||||
|
if (!this._exports || !this._dsp) return;
|
||||||
|
const exports = this._exports;
|
||||||
|
const heap = this._heap;
|
||||||
|
const mem = this._mem;
|
||||||
|
|
||||||
|
// Allocate output buffers in WASM heap (crude bump allocator using high addresses)
|
||||||
|
const heapBytes = mem.buffer.byteLength;
|
||||||
|
const outLOff = (heapBytes >> 2) - blockSize * 2 - 64;
|
||||||
|
const outROff = outLOff + blockSize;
|
||||||
|
|
||||||
|
// Allocate pointer arrays for outputs
|
||||||
|
const ptrSize = 4; // 32-bit pointers in wasm32
|
||||||
|
const outPtrsOff = outROff + blockSize;
|
||||||
|
const i32 = this._heapi32;
|
||||||
|
i32[outPtrsOff] = outLOff * 4; // byte offset in memory
|
||||||
|
i32[outPtrsOff + 1] = outROff * 4;
|
||||||
|
|
||||||
|
exports.compute(this._dsp, blockSize, 0 /* no inputs */, outPtrsOff * 4);
|
||||||
|
|
||||||
|
for (let i = 0; i < blockSize; i++) {
|
||||||
|
outL[i] = heap[outLOff + i];
|
||||||
|
outR[i] = heap[outROff + i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('fm-matrix-processor', FMMatrixProcessor);
|
||||||
|
|
@ -1,13 +1,243 @@
|
||||||
// fm-matrix.dsp — placeholder 2-op FM synthesizer
|
// fm-matrix.dsp — Full 56-parameter 4-operator FM synthesizer with continuous routing matrix
|
||||||
// Carrier modulated by a single operator.
|
//
|
||||||
// This is a pipeline-proving stub; the full 56-param FM matrix engine
|
// Design: 4 operators with fully continuous N×N cross-modulation matrix.
|
||||||
// is implemented in meml-wgg.
|
// No fixed algorithm slots — the algorithm emerges from modulation index values.
|
||||||
|
// When all routing matrix indices are near 0: additive synthesis.
|
||||||
|
// As indices grow: increasingly complex FM timbres emerge.
|
||||||
|
//
|
||||||
|
// Parameter ordering (56 total, maps to MLP output indices 0–55):
|
||||||
|
// 0–23: Operator Core × 4 (ratio, level, attack, decay, sustain, release)
|
||||||
|
// 24–35: Cross-Modulation Matrix (12 directed pairs)
|
||||||
|
// 36–39: Self-Feedback × 4
|
||||||
|
// 40–47: Global Modulation (LFO, pitch env, velocity)
|
||||||
|
// 48–55: Master (level, vel sens, glide, fine tune, waveform blend, stereo spread,
|
||||||
|
// output saturation, output HP)
|
||||||
|
//
|
||||||
|
// Hidden params (not in the 56-param NISPS list):
|
||||||
|
// freq — set by noteOn (Hz)
|
||||||
|
// gate — set by noteOn/noteOff (0/1)
|
||||||
|
// _vel — set by noteOn (velocity 0–1)
|
||||||
|
//
|
||||||
|
// FM implementation: each operator phase-modulates others via a 1-sample delayed
|
||||||
|
// feedback bus. The 4 operator outputs are collected into a 4-channel bus and
|
||||||
|
// looped via ~. This is the standard Faust FM pattern.
|
||||||
|
//
|
||||||
|
// Build:
|
||||||
|
// nix-shell -p faust --run \
|
||||||
|
// "faust -lang wasm -cn fm_matrix fm-matrix.dsp -o fm-matrix.wasm"
|
||||||
|
|
||||||
import("stdfaust.lib");
|
import("stdfaust.lib");
|
||||||
|
|
||||||
freq = hslider("freq[unit:Hz]", 220, 20, 4000, 0.1);
|
// ---------------------------------------------------------------------------
|
||||||
ratio = hslider("ratio", 2.0, 0.125, 16.0, 0.001);
|
// Hidden control params (driven by noteOn/noteOff, not NISPS)
|
||||||
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);
|
freq = hslider("Master/freq[hidden:1][unit:Hz]", 220, 20, 4000, 0.01);
|
||||||
process = amp * os.osc(freq + index * mod * freq) <: _,_;
|
gate = button("Master/gate[hidden:1]");
|
||||||
|
vel = hslider("Master/_vel[hidden:1]", 0.7, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Group 1 — Operator Core × 4 (params 0–23)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
op1_ratio = hslider("Operators/Op 1/op1_ratio", 1.0, 0.125, 16.0, 0.001) : si.smoo;
|
||||||
|
op1_level = hslider("Operators/Op 1/op1_level", 0.8, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
op1_attack = hslider("Operators/Op 1/op1_attack", 0.01, 0.001, 5.0, 0.001);
|
||||||
|
op1_decay = hslider("Operators/Op 1/op1_decay", 0.3, 0.001, 10.0, 0.001);
|
||||||
|
op1_sustain = hslider("Operators/Op 1/op1_sustain", 0.7, 0.0, 1.0, 0.001);
|
||||||
|
op1_release = hslider("Operators/Op 1/op1_release", 0.5, 0.01, 10.0, 0.001);
|
||||||
|
|
||||||
|
op2_ratio = hslider("Operators/Op 2/op2_ratio", 2.0, 0.125, 16.0, 0.001) : si.smoo;
|
||||||
|
op2_level = hslider("Operators/Op 2/op2_level", 0.6, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
op2_attack = hslider("Operators/Op 2/op2_attack", 0.01, 0.001, 5.0, 0.001);
|
||||||
|
op2_decay = hslider("Operators/Op 2/op2_decay", 0.3, 0.001, 10.0, 0.001);
|
||||||
|
op2_sustain = hslider("Operators/Op 2/op2_sustain", 0.7, 0.0, 1.0, 0.001);
|
||||||
|
op2_release = hslider("Operators/Op 2/op2_release", 0.5, 0.01, 10.0, 0.001);
|
||||||
|
|
||||||
|
op3_ratio = hslider("Operators/Op 3/op3_ratio", 3.0, 0.125, 16.0, 0.001) : si.smoo;
|
||||||
|
op3_level = hslider("Operators/Op 3/op3_level", 0.4, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
op3_attack = hslider("Operators/Op 3/op3_attack", 0.01, 0.001, 5.0, 0.001);
|
||||||
|
op3_decay = hslider("Operators/Op 3/op3_decay", 0.3, 0.001, 10.0, 0.001);
|
||||||
|
op3_sustain = hslider("Operators/Op 3/op3_sustain", 0.7, 0.0, 1.0, 0.001);
|
||||||
|
op3_release = hslider("Operators/Op 3/op3_release", 0.5, 0.01, 10.0, 0.001);
|
||||||
|
|
||||||
|
op4_ratio = hslider("Operators/Op 4/op4_ratio", 0.5, 0.125, 16.0, 0.001) : si.smoo;
|
||||||
|
op4_level = hslider("Operators/Op 4/op4_level", 0.3, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
op4_attack = hslider("Operators/Op 4/op4_attack", 0.01, 0.001, 5.0, 0.001);
|
||||||
|
op4_decay = hslider("Operators/Op 4/op4_decay", 0.3, 0.001, 10.0, 0.001);
|
||||||
|
op4_sustain = hslider("Operators/Op 4/op4_sustain", 0.7, 0.0, 1.0, 0.001);
|
||||||
|
op4_release = hslider("Operators/Op 4/op4_release", 0.5, 0.01, 10.0, 0.001);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Group 2 — Cross-Modulation Matrix (params 24–35)
|
||||||
|
// mXY = op X modulates op Y (X's output is added to Y's phase)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
m12 = hslider("Matrix/m12", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
m13 = hslider("Matrix/m13", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
m14 = hslider("Matrix/m14", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
m21 = hslider("Matrix/m21", 1.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
m23 = hslider("Matrix/m23", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
m24 = hslider("Matrix/m24", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
m31 = hslider("Matrix/m31", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
m32 = hslider("Matrix/m32", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
m34 = hslider("Matrix/m34", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
m41 = hslider("Matrix/m41", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
m42 = hslider("Matrix/m42", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
m43 = hslider("Matrix/m43", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Group 3 — Self-Feedback × 4 (params 36–39)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fb1 = hslider("Feedback/fb1", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
fb2 = hslider("Feedback/fb2", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
fb3 = hslider("Feedback/fb3", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
fb4 = hslider("Feedback/fb4", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Group 4 — Global Modulation (params 40–47)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
lfo_rate = hslider("Global/lfo_rate", 5.0, 0.01, 20.0, 0.001);
|
||||||
|
lfo_depth = hslider("Global/lfo_depth", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
lfo_pitch = hslider("Global/lfo_pitch", 0.5, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
lfo_levels = hslider("Global/lfo_levels", 0.5, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
lfo_waveform = hslider("Global/lfo_waveform", 0.0, 0.0, 1.0, 0.001);
|
||||||
|
pitch_env_amount = hslider("Global/pitch_env_amount", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
pitch_env_decay = hslider("Global/pitch_env_decay", 0.1, 0.001, 2.0, 0.001);
|
||||||
|
vel_index_scale = hslider("Global/vel_index_scale", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Group 5 — Master (params 48–55)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
master_level = hslider("Master/level", 0.7, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
vel_sens = hslider("Master/vel_sens", 0.5, 0.0, 1.0, 0.001);
|
||||||
|
pitch_glide = hslider("Master/pitch_glide", 0.0, 0.0, 10.0, 0.001);
|
||||||
|
fine_tune = hslider("Master/fine_tune[unit:cents]", 0.0, -50.0, 50.0, 0.01);
|
||||||
|
waveform_blend = hslider("Master/waveform_blend", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
stereo_spread = hslider("Master/stereo_spread", 0.1, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
output_saturation = hslider("Master/output_saturation", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||||
|
output_hp = hslider("Master/output_hp[unit:Hz]", 20.0, 20.0, 200.0, 0.1);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Derived values
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Cents → frequency ratio
|
||||||
|
cent2ratio(c) = pow(2.0, c / 1200.0);
|
||||||
|
|
||||||
|
// Base frequency with fine-tune
|
||||||
|
base_freq = freq * cent2ratio(fine_tune);
|
||||||
|
|
||||||
|
// Velocity gain: blend between 1.0 (no velocity) and actual velocity
|
||||||
|
vel_gain = (1.0 - vel_sens) + vel_sens * vel;
|
||||||
|
|
||||||
|
// Velocity-scaled FM index multiplier
|
||||||
|
// vel_index_scale=0: velocity doesn't affect FM indices
|
||||||
|
// vel_index_scale=1: FM indices are fully scaled by velocity
|
||||||
|
vel_idx_mult = (1.0 - vel_index_scale) + vel_index_scale * vel;
|
||||||
|
|
||||||
|
// LFO: 4 waveforms crossfaded
|
||||||
|
// Segments: [0,0.333)=sine→tri, [0.333,0.667)=tri→saw, [0.667,1]=saw→square
|
||||||
|
lfo_sig =
|
||||||
|
ba.if(lfo_waveform < 0.333,
|
||||||
|
(1.0 - lfo_waveform*3.0) * os.osc(lfo_rate) + lfo_waveform*3.0 * os.triangle(lfo_rate),
|
||||||
|
ba.if(lfo_waveform < 0.667,
|
||||||
|
(1.0 - (lfo_waveform-0.333)*3.0) * os.triangle(lfo_rate) + (lfo_waveform-0.333)*3.0 * os.sawtooth(lfo_rate),
|
||||||
|
(1.0 - (lfo_waveform-0.667)*3.0) * os.sawtooth(lfo_rate) + (lfo_waveform-0.667)*3.0 * os.square(lfo_rate)));
|
||||||
|
|
||||||
|
// Pitch envelope: fast attack, configurable decay, triggered by gate
|
||||||
|
pitch_env = en.ar(0.001, pitch_env_decay, gate);
|
||||||
|
|
||||||
|
// Modulated frequency: fine-tune + LFO pitch + pitch envelope
|
||||||
|
// LFO pitch: ±0.0833 semitones per unit depth (subtle vibrato)
|
||||||
|
// Pitch env: up to +1 octave (ratio +1.0 = +octave)
|
||||||
|
lfo_pitch_offset = lfo_sig * lfo_depth * lfo_pitch * 0.0083; // ~0.1 semitone max
|
||||||
|
pitch_env_offset = pitch_env * pitch_env_amount; // 0..1 oct
|
||||||
|
mod_freq = base_freq * pow(2.0, lfo_pitch_offset + pitch_env_offset);
|
||||||
|
|
||||||
|
// LFO level modulation (tremolo): 1.0 when depth=0, dips to (1-0.5*depth*lfo_levels) at trough
|
||||||
|
lfo_level_factor = 1.0 - lfo_depth * lfo_levels * 0.5 * (1.0 - lfo_sig) * 0.5;
|
||||||
|
|
||||||
|
// Per-operator oscillator: blend sine (0) → triangle (1)
|
||||||
|
op_osc(f) = (1.0 - waveform_blend) * os.osc(f) + waveform_blend * os.triangle(f);
|
||||||
|
|
||||||
|
// Per-operator ADSR envelopes
|
||||||
|
env1 = en.adsr(op1_attack, op1_decay, op1_sustain, op1_release, gate);
|
||||||
|
env2 = en.adsr(op2_attack, op2_decay, op2_sustain, op2_release, gate);
|
||||||
|
env3 = en.adsr(op3_attack, op3_decay, op3_sustain, op3_release, gate);
|
||||||
|
env4 = en.adsr(op4_attack, op4_decay, op4_sustain, op4_release, gate);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4-Operator FM Routing
|
||||||
|
//
|
||||||
|
// Implementation strategy: build a recursive 4-channel bus using ~
|
||||||
|
// The bus carries (op1_out, op2_out, op3_out, op4_out) with 1-sample delay.
|
||||||
|
// Each frame, we compute new outputs from delayed previous outputs.
|
||||||
|
//
|
||||||
|
// fmbus: (d1,d2,d3,d4) → (op1_out,op2_out,op3_out,op4_out)
|
||||||
|
// where dN are the 1-sample-delayed previous outputs (via ~)
|
||||||
|
//
|
||||||
|
// FM phase deviation: deviation_hz = index * modulator_out * carrier_freq
|
||||||
|
// This is the standard "frequency modulation" formula where modulator_out ∈ [-1,1]
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fmbus(d1,d2,d3,d4) =
|
||||||
|
op1_out, op2_out, op3_out, op4_out
|
||||||
|
with {
|
||||||
|
// Effective indices scaled by velocity
|
||||||
|
m12e = m12 * vel_idx_mult; m13e = m13 * vel_idx_mult; m14e = m14 * vel_idx_mult;
|
||||||
|
m21e = m21 * vel_idx_mult; m23e = m23 * vel_idx_mult; m24e = m24 * vel_idx_mult;
|
||||||
|
m31e = m31 * vel_idx_mult; m32e = m32 * vel_idx_mult; m34e = m34 * vel_idx_mult;
|
||||||
|
m41e = m41 * vel_idx_mult; m42e = m42 * vel_idx_mult; m43e = m43 * vel_idx_mult;
|
||||||
|
fb1e = fb1 * vel_idx_mult; fb2e = fb2 * vel_idx_mult;
|
||||||
|
fb3e = fb3 * vel_idx_mult; fb4e = fb4 * vel_idx_mult;
|
||||||
|
|
||||||
|
f1 = mod_freq * op1_ratio;
|
||||||
|
f2 = mod_freq * op2_ratio;
|
||||||
|
f3 = mod_freq * op3_ratio;
|
||||||
|
f4 = mod_freq * op4_ratio;
|
||||||
|
|
||||||
|
op1_out = env1 * op1_level * lfo_level_factor * vel_gain *
|
||||||
|
op_osc(f1 + (m21e*d2 + m31e*d3 + m41e*d4 + fb1e*d1) * f1);
|
||||||
|
|
||||||
|
op2_out = env2 * op2_level * lfo_level_factor * vel_gain *
|
||||||
|
op_osc(f2 + (m12e*d1 + m32e*d3 + m42e*d4 + fb2e*d2) * f2);
|
||||||
|
|
||||||
|
op3_out = env3 * op3_level * lfo_level_factor * vel_gain *
|
||||||
|
op_osc(f3 + (m13e*d1 + m23e*d2 + m43e*d4 + fb3e*d3) * f3);
|
||||||
|
|
||||||
|
op4_out = env4 * op4_level * lfo_level_factor * vel_gain *
|
||||||
|
op_osc(f4 + (m14e*d1 + m24e*d2 + m34e*d3 + fb4e*d4) * f4);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Route 4-channel bus through feedback loop (introduces mandatory 1-sample delay)
|
||||||
|
// Output of fmbus is (op1,op2,op3,op4); sum all for audio out
|
||||||
|
fm_out = (fmbus ~ (si.bus(4))) : (_, _, _, _) :> _;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Post-processing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Soft clip: tanh approximation (drive controlled by output_saturation)
|
||||||
|
// At 0: linear passthrough. At 1: clips at ±~0.6
|
||||||
|
soft_clip(x) = x / max(0.001, 1.0 + output_saturation * abs(x));
|
||||||
|
|
||||||
|
// Stereo spread via slight pitch detuning L vs R
|
||||||
|
spread_cents = stereo_spread * 5.0; // ±5 cents max
|
||||||
|
|
||||||
|
// HP filter removes DC offset from heavy feedback FM
|
||||||
|
hp_out(x) = fi.highpass(1, output_hp, x);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// process: mono FM sum → scale → soft-clip → HP → stereo spread
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
process =
|
||||||
|
fm_out
|
||||||
|
: *(master_level * 0.25) // 4 ops summing: scale to safe range
|
||||||
|
: soft_clip
|
||||||
|
: hp_out
|
||||||
|
<: *(cent2ratio(spread_cents)), *(cent2ratio(0.0 - spread_cents));
|
||||||
|
|
|
||||||
1
playground/faust/fm-matrix.json
Normal file
1
playground/faust/fm-matrix.json
Normal file
File diff suppressed because one or more lines are too long
89
playground/faust/fm-matrix.wasm
Normal file
89
playground/faust/fm-matrix.wasm
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue