256 lines
8.1 KiB
HTML
256 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>
|