memlnaut-nisps/playground/faust/modular-subtractive-smoke.html
monkey-w1n5t0n afff406d92 feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.

Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table

JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
  setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
  per-slot enable switches, matrix grid editor (tap-cycle, long-press
  precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
  DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
  modular DSP state persisted under modularDspState, window.__nisps
  debug hooks for programmatic control

Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).

Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00

255 lines
8.1 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Modular Subtractive — Smoke Test</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
background: #111;
color: #eee;
}
h1 { margin-bottom: 0.5rem; }
.sub { color: #aaa; margin-top: 0; }
button {
font-size: 1.1rem;
padding: 0.7rem 1.4rem;
margin-right: 0.5rem;
margin-top: 0.5rem;
border-radius: 0.4rem;
border: 1px solid #555;
background: #222;
color: #eee;
cursor: pointer;
}
button:hover { background: #333; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.big { font-size: 1.4rem; padding: 1rem 2rem; background: #2a5; }
.big:hover { background: #3c7; }
#log {
margin-top: 1rem;
padding: 0.8rem;
background: #000;
border: 1px solid #333;
border-radius: 0.4rem;
font-family: ui-monospace, Menlo, monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 360px;
overflow: auto;
}
#meter {
display: inline-block;
width: 260px;
height: 22px;
background: #111;
border: 1px solid #555;
vertical-align: middle;
border-radius: 0.2rem;
overflow: hidden;
}
#meter-fill {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, #2a5, #fd5 70%, #f44 90%);
transition: width 0.05s linear;
}
</style>
</head>
<body>
<h1>modular-subtractive smoke test</h1>
<p class="sub">Phase A gate. Loads the worklet, installs a default patch (ADSR 1 → amp), plays a 1 s note.</p>
<button id="init">1. Initialise audio</button>
<button id="patch" disabled>2. Apply default patch</button>
<button id="play" class="big" disabled>3. Play note (440 Hz, 1 s)</button>
<p>
Output level: <span id="meter"><span id="meter-fill"></span></span>
<span id="meter-val" style="font-family:monospace; color:#aaa;"> 0.000</span>
</p>
<div id="log"></div>
<script>
const logEl = document.getElementById('log');
const log = (...args) => {
const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
console.log(...args);
logEl.textContent += msg + '\n';
logEl.scrollTop = logEl.scrollHeight;
};
let audioCtx = null;
let workletNode = null;
let analyser = null;
let meterRAF = null;
const btnInit = document.getElementById('init');
const btnPatch = document.getElementById('patch');
const btnPlay = document.getElementById('play');
btnInit.addEventListener('click', async () => {
btnInit.disabled = true;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
await audioCtx.resume();
log('AudioContext created, state=' + audioCtx.state + ', sr=' + audioCtx.sampleRate);
// Load base class (defines FaustWorkletProcessor in worklet scope)
await audioCtx.audioWorklet.addModule('faust-worklet-processor.js');
log('Loaded faust-worklet-processor.js');
// Load engine-specific processor
await audioCtx.audioWorklet.addModule('modular-subtractive-processor.js');
log('Loaded modular-subtractive-processor.js');
workletNode = new AudioWorkletNode(audioCtx, 'modular-subtractive-processor', {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [2],
});
log('Created AudioWorkletNode');
// Ready / error messages
await new Promise((resolve, reject) => {
const to = setTimeout(() => reject(new Error('worklet ready timeout')), 10000);
workletNode.port.onmessage = (e) => {
if (e.data?.type === 'ready') {
clearTimeout(to);
log('Worklet reported ready');
resolve();
} else if (e.data?.type === 'error') {
clearTimeout(to);
reject(new Error('worklet error: ' + e.data.message));
}
};
// Fetch both WASM binary and JSON UI descriptor. We parse the JSON
// on the main thread and pass the already-parsed object to the
// worklet — the wasm-native JSON has `index` fields that the worklet
// uses directly as setParamValue zone addresses.
Promise.all([
fetch('modular-subtractive.wasm').then(r => r.arrayBuffer()),
fetch('modular-subtractive.json').then(r => r.json()),
]).then(([bytes, uiJson]) => {
const paramCount = countUiParams(uiJson);
log('Fetched WASM ' + bytes.byteLength + ' bytes, JSON ' + paramCount + ' params; sending init');
workletNode.port.postMessage({
type: 'init',
wasmBytes: bytes,
sampleRate: audioCtx.sampleRate,
uiJson,
}, [bytes]);
}).catch(reject);
});
// Hook up analyser for the meter
analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
workletNode.connect(analyser);
analyser.connect(audioCtx.destination);
startMeter();
btnPatch.disabled = false;
log('Ready.');
} catch (err) {
log('INIT FAILED: ' + (err?.message || err));
console.error(err);
btnInit.disabled = false;
}
});
btnPatch.addEventListener('click', () => {
if (!workletNode) return;
// Default patch:
// ADSR 1 enabled with A=0.01, D=0.2, S=0.7, R=0.3
// Matrix s00_d08_amp = 1.0 (src00 = adsr01 → dest 8 = amp)
//
// Uses the setByLabel custom message to bypass paramMeta index ordering
// (which is dependent on the faust-param-meta parser and not loaded here).
const writes = [
// ADSR 1 — enabled by default but re-send to be explicit
['MM_ADSR/00_adsr01_enable', 1.0],
['MM_ADSR/00_adsr01_attack', 0.01],
['MM_ADSR/00_adsr01_decay', 0.2],
['MM_ADSR/00_adsr01_sustain', 0.7],
['MM_ADSR/00_adsr01_release', 0.3],
// Matrix: src00 (adsr01) → d08 (amp), amount 1.0
['MM_Matrix/s00_d08_amp', 1.0],
// Engine defaults known sane: set filter cutoff high, reasonable osc1 level
['3_Filter/00_cutoff', 3000],
['3_Filter/01_resonance', 0.2],
['1_Oscillators/02_osc1_level', 0.8],
// osc2/3 silenced to keep the smoke test clean
['1_Oscillators/06_osc2_level', 0.0],
['1_Oscillators/10_osc3_level', 0.0],
// Master level
['4_Master/00_master_level', 0.7],
];
for (const [label, value] of writes) {
workletNode.port.postMessage({ type: 'setByLabel', label, value });
}
log('Applied default patch (' + writes.length + ' writes)');
btnPlay.disabled = false;
});
btnPlay.addEventListener('click', () => {
if (!workletNode) return;
// 440 Hz = MIDI note 69 (A4). Use noteOn/noteOff with freq directly.
const freq = 440;
workletNode.port.postMessage({ type: 'noteOn', freq, vel: 0.8 });
log('noteOn freq=' + freq);
setTimeout(() => {
workletNode.port.postMessage({ type: 'noteOff', freq });
log('noteOff freq=' + freq);
}, 1000);
});
function countUiParams(json) {
let n = 0;
const walk = (items) => {
if (!Array.isArray(items)) return;
for (const it of items) {
if (['hslider','vslider','nentry','button','checkbox'].includes(it.type)) n++;
if (it.items) walk(it.items);
}
};
walk(json.ui ?? []);
return n;
}
function startMeter() {
if (!analyser) return;
const fillEl = document.getElementById('meter-fill');
const valEl = document.getElementById('meter-val');
const buf = new Float32Array(analyser.fftSize);
let peakDecay = 0;
const tick = () => {
analyser.getFloatTimeDomainData(buf);
let peak = 0;
for (let i = 0; i < buf.length; i++) {
const a = Math.abs(buf[i]);
if (a > peak) peak = a;
}
peakDecay = Math.max(peak, peakDecay * 0.92);
fillEl.style.width = Math.min(100, peakDecay * 100) + '%';
valEl.textContent = ' ' + peakDecay.toFixed(3);
meterRAF = requestAnimationFrame(tick);
};
tick();
}
</script>
</body>
</html>