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).
255 lines
8.1 KiB
HTML
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>
|