memlnaut-nisps/playground/js/ui/modular-ui.js
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

1466 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MODULAR_PRESETS, applyPreset } from '../synth/modular-presets.js';
/**
* modular-ui.js — Phase C of the "Modular" audio mode.
*
* User-facing control panel for ModularEngine:
* - Sub-engine toggle (Subtractive / Additive / FM)
* - Mod pool count steppers (ADSR 1..16, LFO 1..32)
* - Per-slot enable toggles (independent of count)
* - Matrix grid editor (sources × destinations, signed [-1, 1] amounts)
* - Expose engine sound params to the MLP
*
* The panel is mounted as a drawer in the existing `#drawer-stack`, with a
* matching dock icon injected into `#dock`. Both are hidden until the
* modular engine becomes active.
*
* Matrix paramMeta layout (from Phase B, VERIFIED at runtime via label
* prefix `MM_Matrix/` on rebuild): mod sources first (ADSR + LFO), matrix
* cells second (dest-major, source-major within each destination), opted-in
* engine sound params last. We discover matrix cells by group prefix, not
* by hardcoded offsets, so that count changes / sub-engine swaps are safe.
*
* Raw matrix cell range is [-1, 1]; paramMeta normalises to [0, 1] via
* `(raw - min) / (max - min)`. UI uses signed values directly and converts
* `(v + 1) / 2` when calling `engine.setParam(i, norm01)`.
*
* @module modular-ui
*/
// ---------------------------------------------------------------------------
// Module-level constants
// ---------------------------------------------------------------------------
const STORAGE_KEY = 'nisps-modular-state';
const MAX_ADSR = 16;
const MAX_LFO = 32;
// Matrix cell cycle on left-click (positive only). Shift-click → 0.
// Right-click opens a menu with ±1 / 0 / random.
const CLICK_CYCLE = [0, 0.25, 0.5, 0.75, 1.0];
const SUB_ENGINE_OPTIONS = [
{ id: 'subtractive', label: 'Subtractive' },
{ id: 'additive', label: 'Additive' },
{ id: 'fm', label: 'FM' },
];
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Initialise the Modular UI. Creates the drawer and dock icon (hidden), and
* wires them to the engine returned by `getEngine()`.
*
* @param {object} opts
* @param {() => object} opts.getEngine — returns current activeEngine
* @param {() => void} [opts.onStateChange] — optional hook when UI state
* persists to localStorage
* @returns {{ teardown, refresh, show, hide, isVisible }}
*/
export function initModularUI({ getEngine, onStateChange } = {}) {
injectStyles();
const dockIcon = createDockIcon();
const drawer = createDrawer();
const uiState = loadUIState();
// Subscription handle for the active engine's paramMeta:change event.
let unsubParamMeta = null;
let lastWiredEngine = null;
// DOM refs — populated by the section builders.
const refs = {
subEngineBtns: [],
adsrCountValue: null,
lfoCountValue: null,
adsrEnables: [],
lfoEnables: [],
matrixGrid: null,
matrixEmpty: null,
soundParamList: null,
};
// ---- Build panel sections ----
buildSubEngineSection(drawer.body, refs, applySubEngineChange);
buildPresetsSection(drawer.body, openPresetOverlay);
buildCountSection(drawer.body, refs, applyCountChange);
buildEnableSection(drawer.body, refs, applyEnableChange);
buildMatrixSection(drawer.body, refs, {
onCellCycle: (src, dst) => cycleCell(src, dst),
onCellInvert: (src, dst) => setCell(src, dst, -getCell(src, dst)),
onCellZero: (src, dst) => setCell(src, dst, 0),
onCellPrecise: (src, dst, el) => openPreciseEditor(src, dst, el),
onCellMenu: (src, dst, el, e) => openCellMenu(src, dst, el, e),
});
buildSoundParamSection(drawer.body, refs, applyExposeChange);
// ---- Apply saved UI state to engine as soon as we can ----
// We defer engine mutations until refresh() is called with a live engine.
let pendingRestore = true;
// ---------------------------------------------------------------------------
// Engine binding
// ---------------------------------------------------------------------------
function bindEngine(engine) {
if (engine === lastWiredEngine) return;
if (unsubParamMeta) { try { unsubParamMeta(); } catch (_) { /* ignore */ } }
unsubParamMeta = null;
lastWiredEngine = engine;
if (engine && typeof engine.on === 'function') {
unsubParamMeta = engine.on('paramMeta:change', () => {
rebuildMatrixGrid();
rebuildSoundParamList();
refreshSubEngineButtons();
});
}
}
// ---------------------------------------------------------------------------
// Build: sub-engine section
// ---------------------------------------------------------------------------
function refreshSubEngineButtons() {
const engine = getEngine?.();
if (!engine || engine.id !== 'modular') return;
const available = new Set(engine.availableSubEngines || []);
const active = engine.activeSubEngineId;
for (const btn of refs.subEngineBtns) {
const id = btn.dataset.subEngine;
btn.classList.toggle('active', id === active);
btn.disabled = !available.has(id);
btn.title = available.has(id)
? `Use ${id} sub-engine`
: `${id} not available yet`;
}
}
async function applySubEngineChange(id) {
const engine = getEngine?.();
if (!engine || engine.id !== 'modular') return;
if (id === engine.activeSubEngineId) return;
refs.subEngineBtns.forEach(b => b.classList.toggle('busy', b.dataset.subEngine === id));
try {
await engine.setSubEngine(id);
uiState.subEngine = id;
saveUIState();
// paramMeta:change listener rebuilds the grid
} catch (err) {
console.error('[modular-ui] setSubEngine failed:', err);
} finally {
refs.subEngineBtns.forEach(b => b.classList.remove('busy'));
refreshSubEngineButtons();
}
}
// ---------------------------------------------------------------------------
// Build: count section
// ---------------------------------------------------------------------------
function applyCountChange(kind, newCount) {
const engine = getEngine?.();
if (!engine || engine.id !== 'modular') return;
const adsr = kind === 'adsr' ? newCount : engine.adsrCount;
const lfo = kind === 'lfo' ? newCount : engine.lfoCount;
engine.setModSourceCount?.(adsr, lfo);
uiState.adsrCount = adsr;
uiState.lfoCount = lfo;
// Ensure enable arrays are long enough
while (uiState.adsrEnables.length < MAX_ADSR) uiState.adsrEnables.push(false);
while (uiState.lfoEnables.length < MAX_LFO) uiState.lfoEnables.push(false);
// Newly-in-count slots default to enabled (if previously disabled).
for (let i = 0; i < adsr; i++) if (!uiState.adsrEnables[i]) uiState.adsrEnables[i] = true;
for (let i = 0; i < lfo; i++) if (!uiState.lfoEnables[i]) uiState.lfoEnables[i] = true;
applyAllEnablesToEngine();
saveUIState();
refreshCountValues();
refreshEnableRow();
rebuildMatrixGrid();
}
function refreshCountValues() {
if (refs.adsrCountValue) refs.adsrCountValue.textContent = String(uiState.adsrCount);
if (refs.lfoCountValue) refs.lfoCountValue.textContent = String(uiState.lfoCount);
}
// ---------------------------------------------------------------------------
// Build: enable switches
// ---------------------------------------------------------------------------
function refreshEnableRow() {
const active = getEngine?.();
for (let i = 0; i < MAX_ADSR; i++) {
const btn = refs.adsrEnables[i];
if (!btn) continue;
const inCount = i < uiState.adsrCount;
btn.classList.toggle('active', !!uiState.adsrEnables[i]);
btn.classList.toggle('out-of-count', !inCount);
btn.title = inCount
? `ADSR ${i + 1}: ${uiState.adsrEnables[i] ? 'enabled' : 'disabled'}`
: `ADSR ${i + 1}: slot past count (not MLP-driven)`;
}
for (let i = 0; i < MAX_LFO; i++) {
const btn = refs.lfoEnables[i];
if (!btn) continue;
const inCount = i < uiState.lfoCount;
btn.classList.toggle('active', !!uiState.lfoEnables[i]);
btn.classList.toggle('out-of-count', !inCount);
btn.title = inCount
? `LFO ${i + 1}: ${uiState.lfoEnables[i] ? 'enabled' : 'disabled'}`
: `LFO ${i + 1}: slot past count (not MLP-driven)`;
}
}
function applyEnableChange(kind, i, enabled) {
const engine = getEngine?.();
if (!engine || engine.id !== 'modular') return;
if (kind === 'adsr') uiState.adsrEnables[i] = enabled;
else uiState.lfoEnables[i] = enabled;
writeEnableToEngine(engine, kind, i, enabled);
saveUIState();
refreshEnableRow();
}
function writeEnableToEngine(engine, kind, i, enabled) {
if (typeof engine._setRawByLabel !== 'function') return;
const label = (kind === 'adsr')
? `MM_ADSR/${pad2(i)}_adsr${pad2(i + 1)}_enable`
: `MM_LFO/${pad2(i)}_lfo${pad2(i + 1)}_enable`;
engine._setRawByLabel(label, enabled ? 1.0 : 0.0);
}
function applyAllEnablesToEngine() {
const engine = getEngine?.();
if (!engine || engine.id !== 'modular') return;
for (let i = 0; i < MAX_ADSR; i++) {
writeEnableToEngine(engine, 'adsr', i, !!uiState.adsrEnables[i]);
}
for (let i = 0; i < MAX_LFO; i++) {
writeEnableToEngine(engine, 'lfo', i, !!uiState.lfoEnables[i]);
}
}
// ---------------------------------------------------------------------------
// Matrix grid
// ---------------------------------------------------------------------------
/**
* Given engine paramMeta, discover matrix cells by group prefix.
* Returns a Map: `${s}|${d}` -> paramMeta index.
*/
function buildMatrixIndex(engine) {
const out = new Map();
const meta = engine?.paramMeta ?? [];
for (let i = 0; i < meta.length; i++) {
const m = meta[i];
if (!m || !m.group || !m.group.startsWith('Matrix/')) continue;
// Label is like "MM_Matrix/s03_d08_amp" — parse out s and d.
const lbl = m.label || '';
const match = lbl.match(/s(\d{2})_d(\d{2})_/);
if (!match) continue;
const s = parseInt(match[1], 10);
const d = parseInt(match[2], 10);
out.set(`${s}|${d}`, i);
}
return out;
}
// Current cell values (signed [-1, 1]), keyed by "s|d". Populated from
// paramMeta entries on rebuild; mutated by user interaction.
const cellValues = new Map();
function getCell(s, d) { return cellValues.get(`${s}|${d}`) ?? 0; }
function setCell(s, d, value, { persistToEngine = true } = {}) {
const v = Math.max(-1, Math.min(1, value));
cellValues.set(`${s}|${d}`, v);
updateCellDOM(s, d, v);
if (!persistToEngine) return;
const engine = getEngine?.();
if (!engine || engine.id !== 'modular') return;
const idx = matrixIndexCache.get(`${s}|${d}`);
if (idx == null) return; // not in paramMeta (s out of adsr/lfo counts maybe)
const meta = engine.paramMeta[idx];
if (!meta) return;
const range = (meta.max - meta.min) || 1;
// v is the raw value in the paramMeta [min, max] space for matrix cells
// because we set min=-1, max=1 in the DSP — but meta.min/max are raw,
// so normalise generically.
const norm = (v - meta.min) / range;
engine.setParam(idx, Math.max(0, Math.min(1, norm)));
}
function cycleCell(s, d) {
const cur = getCell(s, d);
// Cycle positive steps only, wrapping back to 0.
// Negatives reached via right-click menu or precise editor.
let nextIdx = 0;
// Match to nearest current step, then advance.
if (cur > 0) {
let best = 0, bestD = Infinity;
for (let i = 0; i < CLICK_CYCLE.length; i++) {
const diff = Math.abs(CLICK_CYCLE[i] - cur);
if (diff < bestD) { bestD = diff; best = i; }
}
nextIdx = (best + 1) % CLICK_CYCLE.length;
} else {
// From 0 or negative: jump up to 0.25 to start the cycle.
nextIdx = 1;
}
setCell(s, d, CLICK_CYCLE[nextIdx]);
}
let matrixIndexCache = new Map();
function rebuildMatrixGrid() {
const engine = getEngine?.();
const gridEl = refs.matrixGrid;
if (!gridEl) return;
gridEl.innerHTML = '';
matrixIndexCache = new Map();
cellValues.clear();
if (!engine || engine.id !== 'modular') {
if (refs.matrixEmpty) refs.matrixEmpty.textContent = 'Modular engine not active.';
return;
}
const destNames = engine.destNames || [];
if (destNames.length === 0) {
if (refs.matrixEmpty) refs.matrixEmpty.textContent = 'Waiting for sub-engine…';
return;
}
matrixIndexCache = buildMatrixIndex(engine);
if (matrixIndexCache.size === 0) {
if (refs.matrixEmpty) refs.matrixEmpty.textContent = 'No matrix cells in paramMeta.';
return;
}
if (refs.matrixEmpty) refs.matrixEmpty.textContent = '';
// Seed current values from paramMeta init (normalized 0..1).
const meta = engine.paramMeta;
for (const [key, idx] of matrixIndexCache.entries()) {
const m = meta[idx];
const range = (m.max - m.min) || 1;
const raw = m.min + m.init * range;
cellValues.set(key, raw);
}
// Determine visible source range from current counts.
const adsrN = uiState.adsrCount;
const lfoN = uiState.lfoCount;
const visibleSources = [];
for (let i = 0; i < adsrN; i++) visibleSources.push({ s: i, label: `A${i + 1}`, kind: 'adsr' });
for (let i = 0; i < lfoN; i++) visibleSources.push({ s: 16 + i, label: `L${i + 1}`, kind: 'lfo' });
// NOTE: source index layout — sources 0..15 are ADSRs, 16..47 are LFOs.
// This mirrors the Faust .dsp's MM_* declaration order, VERIFIED by the
// walk-entry labels: matrix column s00..s15 are ADSRs, s16..s47 are LFOs.
const cols = destNames.length;
const rows = visibleSources.length;
// CSS grid: 1 header col + N dest cols × 1 header row + M source rows
gridEl.style.gridTemplateColumns = `auto repeat(${cols}, minmax(28px, 1fr))`;
gridEl.style.gridTemplateRows = `auto repeat(${rows}, 32px)`;
// Top-left corner
const corner = document.createElement('div');
corner.className = 'mm-corner';
gridEl.appendChild(corner);
// Column headers
destNames.forEach((name, d) => {
const h = document.createElement('div');
h.className = 'mm-col-header';
h.textContent = shortDest(name);
h.title = name;
gridEl.appendChild(h);
});
// Rows
visibleSources.forEach((src) => {
const rh = document.createElement('div');
rh.className = `mm-row-header mm-src-${src.kind}`;
rh.textContent = src.label;
rh.title = src.kind === 'adsr' ? `ADSR ${src.s + 1}` : `LFO ${src.s - 15}`;
gridEl.appendChild(rh);
for (let d = 0; d < cols; d++) {
const cell = document.createElement('div');
cell.className = 'mm-cell';
cell.dataset.s = src.s;
cell.dataset.d = d;
const has = matrixIndexCache.has(`${src.s}|${d}`);
if (!has) cell.classList.add('unavailable');
attachCellHandlers(cell, src.s, d);
gridEl.appendChild(cell);
updateCellDOM(src.s, d, getCell(src.s, d));
}
});
}
function updateCellDOM(s, d, v) {
const cell = refs.matrixGrid?.querySelector(`.mm-cell[data-s="${s}"][data-d="${d}"]`);
if (!cell) return;
const abs = Math.abs(v);
const label = v === 0
? ''
: (v > 0 ? '' : '') + abs.toFixed(2).replace(/^0/, '');
cell.textContent = label;
const alpha = Math.min(1, abs);
const color = v >= 0
? `rgba(255, 106, 0, ${alpha * 0.85})` // accent (positive)
: `rgba(80, 160, 255, ${alpha * 0.85})`; // blue (negative / inverted)
cell.style.background = alpha > 0.01
? color
: 'rgba(255, 255, 255, 0.04)';
cell.classList.toggle('nonzero', alpha > 0.01);
}
function attachCellHandlers(cell, s, d) {
let longPressTimer = null;
let didLongPress = false;
cell.addEventListener('click', (e) => {
if (cell.classList.contains('unavailable')) return;
if (didLongPress) { didLongPress = false; return; }
if (e.shiftKey) { setCell(s, d, 0); return; }
cycleCell(s, d);
});
cell.addEventListener('contextmenu', (e) => {
e.preventDefault();
if (cell.classList.contains('unavailable')) return;
openCellMenu(s, d, cell, e);
});
// Long-press → precise editor (mobile-friendly).
const startLong = () => {
longPressTimer = setTimeout(() => {
didLongPress = true;
openPreciseEditor(s, d, cell);
}, 500);
};
const cancelLong = () => {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
};
cell.addEventListener('pointerdown', startLong);
cell.addEventListener('pointerup', cancelLong);
cell.addEventListener('pointerleave', cancelLong);
cell.addEventListener('pointercancel', cancelLong);
}
function openPreciseEditor(s, d, cellEl) {
const cur = getCell(s, d);
const input = prompt(`Matrix s${pad2(s)} → d${pad2(d)} (range -1..1)`, String(cur));
if (input == null) return;
const v = parseFloat(input);
if (!Number.isFinite(v)) return;
setCell(s, d, v);
}
function openCellMenu(s, d, cellEl, ev) {
const existing = document.getElementById('mm-cell-menu');
if (existing) existing.remove();
const menu = document.createElement('div');
menu.id = 'mm-cell-menu';
menu.className = 'mm-cell-menu';
const actions = [
{ label: 'Set +1', fn: () => setCell(s, d, 1) },
{ label: 'Set +0.5', fn: () => setCell(s, d, 0.5) },
{ label: 'Set 0', fn: () => setCell(s, d, 0) },
{ label: 'Set -0.5', fn: () => setCell(s, d, -0.5) },
{ label: 'Set -1', fn: () => setCell(s, d, -1) },
{ label: 'Invert', fn: () => setCell(s, d, -getCell(s, d)) },
{ label: 'Random', fn: () => setCell(s, d, (Math.random() * 2) - 1) },
{ label: 'Precise…', fn: () => openPreciseEditor(s, d, cellEl) },
];
for (const a of actions) {
const b = document.createElement('button');
b.textContent = a.label;
b.addEventListener('click', () => { a.fn(); menu.remove(); });
menu.appendChild(b);
}
document.body.appendChild(menu);
const rect = cellEl.getBoundingClientRect();
const x = Math.min(window.innerWidth - 180, rect.left);
const y = Math.min(window.innerHeight - menu.offsetHeight - 10, rect.bottom + 4);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
// Dismiss on outside click.
const dismiss = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener('pointerdown', dismiss, true);
}
};
setTimeout(() => document.addEventListener('pointerdown', dismiss, true), 0);
}
// ---------------------------------------------------------------------------
// Sound param exposure
// ---------------------------------------------------------------------------
function rebuildSoundParamList() {
const list = refs.soundParamList;
if (!list) return;
list.innerHTML = '';
const engine = getEngine?.();
if (!engine || engine.id !== 'modular' ||
typeof engine.getEngineSoundParamLabels !== 'function') {
const note = document.createElement('div');
note.className = 'mm-hint';
note.textContent = 'Modular engine not active.';
list.appendChild(note);
return;
}
const entries = engine.getEngineSoundParamLabels();
if (entries.length === 0) {
const note = document.createElement('div');
note.className = 'mm-hint';
note.textContent = 'No engine sound params exposed by this sub-engine.';
list.appendChild(note);
return;
}
// Group by top-level prefix for readability.
const groups = new Map();
for (const e of entries) {
const [group] = splitLabel(e.label);
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push(e);
}
for (const [group, params] of groups.entries()) {
const h = document.createElement('div');
h.className = 'mm-sound-group';
h.textContent = prettyGroup(group);
list.appendChild(h);
for (const p of params) {
const row = document.createElement('label');
row.className = 'mm-sound-row';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = engine.isEngineParamExposed?.(p.label) ?? false;
cb.addEventListener('change', () => applyExposeChange(p.label, cb.checked));
const name = document.createElement('span');
name.textContent = stripIndexPrefix(splitLabel(p.label)[1]);
name.title = p.label;
row.appendChild(cb);
row.appendChild(name);
list.appendChild(row);
}
}
const warning = document.createElement('div');
warning.className = 'mm-hint warn';
warning.textContent =
'Toggling exposes/unexposes a param to the MLP. The network is resized ' +
'in place (training examples are preserved; output layer is warm-started).';
list.appendChild(warning);
}
// ---------------------------------------------------------------------------
// Preset overlay (Phase E)
// ---------------------------------------------------------------------------
async function openPresetOverlay() {
const existing = document.getElementById('mm-preset-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'mm-preset-overlay';
overlay.className = 'mm-preset-overlay';
const panel = document.createElement('div');
panel.className = 'mm-preset-panel';
const header = document.createElement('div');
header.className = 'mm-preset-header';
const title = document.createElement('div');
title.className = 'mm-preset-title';
title.textContent = 'Modular presets';
const closeBtn = document.createElement('button');
closeBtn.className = 'mm-preset-close';
closeBtn.innerHTML = '&times;';
closeBtn.addEventListener('click', () => overlay.remove());
header.appendChild(title);
header.appendChild(closeBtn);
panel.appendChild(header);
const list = document.createElement('div');
list.className = 'mm-preset-list';
for (const preset of MODULAR_PRESETS) {
const item = document.createElement('button');
item.className = 'mm-preset-item';
item.dataset.presetId = preset.id;
const name = document.createElement('div');
name.className = 'mm-preset-name';
name.textContent = preset.name;
const desc = document.createElement('div');
desc.className = 'mm-preset-desc';
desc.textContent = preset.description;
const sub = document.createElement('div');
sub.className = 'mm-preset-sub';
sub.textContent = preset.state?.subEngine ?? '';
item.appendChild(name);
item.appendChild(desc);
item.appendChild(sub);
item.addEventListener('click', async () => {
item.classList.add('busy');
try {
const engine = getEngine?.();
if (engine && engine.id === 'modular') {
await applyPreset(engine, preset);
// Sync Phase C UI state to reflect the preset's sub-engine
// and counts so the rebuild that follows matches the engine.
if (preset.state?.subEngine) uiState.subEngine = preset.state.subEngine;
if (typeof preset.state?.adsrCount === 'number') {
uiState.adsrCount = preset.state.adsrCount;
}
if (typeof preset.state?.lfoCount === 'number') {
uiState.lfoCount = preset.state.lfoCount;
}
saveUIState();
onStateChange?.(); // trigger a-app saveState -> persists DSP snapshot
refresh();
}
} catch (err) {
console.error('[modular-ui] preset apply failed:', err);
} finally {
item.classList.remove('busy');
overlay.remove();
}
});
list.appendChild(item);
}
panel.appendChild(list);
overlay.appendChild(panel);
// Dismiss on backdrop click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
document.body.appendChild(overlay);
}
function applyExposeChange(label, exposed) {
const engine = getEngine?.();
if (!engine || engine.id !== 'modular') return;
engine.setExposeEngineParam?.(label, exposed);
if (exposed) {
if (!uiState.exposedEngineParams.includes(label)) {
uiState.exposedEngineParams.push(label);
}
} else {
uiState.exposedEngineParams = uiState.exposedEngineParams.filter(l => l !== label);
}
saveUIState();
// paramMeta:change listener rebuilds the matrix & list.
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
function show() {
dockIcon.classList.remove('hidden');
const engine = getEngine?.();
if (engine && engine.id === 'modular') bindEngine(engine);
refresh();
}
function hide() {
dockIcon.classList.add('hidden');
dockIcon.classList.remove('active');
drawer.root.classList.add('hidden');
}
function isVisible() {
return !dockIcon.classList.contains('hidden');
}
function refresh() {
const engine = getEngine?.();
if (!engine || engine.id !== 'modular') return;
bindEngine(engine);
// One-time apply of persisted state to the engine.
if (pendingRestore) {
pendingRestore = false;
try {
if (uiState.subEngine && uiState.subEngine !== engine.activeSubEngineId
&& engine.availableSubEngines?.includes(uiState.subEngine)) {
engine.setSubEngine?.(uiState.subEngine);
}
// Sync counts (UI persisted → engine). Guard if method missing.
if (typeof engine.setModSourceCount === 'function') {
engine.setModSourceCount(uiState.adsrCount, uiState.lfoCount);
}
// Re-expose saved sound params.
for (const label of uiState.exposedEngineParams) {
engine.setExposeEngineParam?.(label, true);
}
applyAllEnablesToEngine();
} catch (err) {
console.warn('[modular-ui] restore failed:', err);
}
}
// Keep UI widgets in sync with engine state (in case engine was
// mutated outside of our control — e.g. preset load).
if (engine.adsrCount != null) uiState.adsrCount = engine.adsrCount;
if (engine.lfoCount != null) uiState.lfoCount = engine.lfoCount;
refreshSubEngineButtons();
refreshCountValues();
refreshEnableRow();
rebuildMatrixGrid();
rebuildSoundParamList();
}
function teardown() {
if (unsubParamMeta) { try { unsubParamMeta(); } catch (_) { /* ignore */ } }
unsubParamMeta = null;
dockIcon.remove();
drawer.root.remove();
const style = document.getElementById('modular-ui-styles');
if (style) style.remove();
}
// Start hidden (engine-gated).
hide();
// Listen for close button on our drawer so the dock icon state stays synced.
drawer.closeBtn.addEventListener('click', () => {
drawer.root.classList.add('hidden');
dockIcon.classList.remove('active');
});
// Dock icon opens/closes our drawer. We piggy-back on the existing
// wireDock() event delegation — but since our drawer's id is `drawer-modular`
// and our icon uses `data-drawer="modular"`, wireDock() already handles it.
// However, wireDock was wired at init time — any new dock-click delegation
// works because it's attached to #dock via event delegation.
return {
teardown,
refresh,
show,
hide,
isVisible,
/** Exposed for testability. */
_debug: {
getCell,
setCell,
cycleCell,
uiState: () => ({ ...uiState }),
},
};
// ===========================================================================
// Section builders
// ===========================================================================
function buildSubEngineSection(body, refs, onChange) {
const section = el('div', 'mm-section');
section.appendChild(el('div', 'mm-section-header', 'Sub-engine'));
const row = el('div', 'mm-row mm-sub-row');
for (const opt of SUB_ENGINE_OPTIONS) {
const b = el('button', 'mm-sub-btn', opt.label);
b.dataset.subEngine = opt.id;
b.addEventListener('click', () => onChange(opt.id));
refs.subEngineBtns.push(b);
row.appendChild(b);
}
section.appendChild(row);
body.appendChild(section);
}
function buildPresetsSection(body, onOpen) {
const section = el('div', 'mm-section');
const header = el('div', 'mm-section-header', 'Presets');
section.appendChild(header);
const btn = el('button', 'mm-preset-open-btn', 'Load preset…');
btn.addEventListener('click', onOpen);
section.appendChild(btn);
body.appendChild(section);
}
function buildCountSection(body, refs, onCountChange) {
const section = el('div', 'mm-section');
section.appendChild(el('div', 'mm-section-header', 'Mod Pool'));
const hint = el('div', 'mm-hint',
'Slots past the count stay alive in the DSP but are not MLP-driven. ' +
'Changing counts reinitialises the MLP.');
section.appendChild(hint);
const mkStepper = (labelText, kind, max) => {
const row = el('div', 'mm-row mm-stepper-row');
row.appendChild(el('label', 'mm-label', labelText));
const dec = el('button', 'mm-step-btn', '');
const val = el('span', 'mm-step-val', String(uiState[kind === 'adsr' ? 'adsrCount' : 'lfoCount']));
const inc = el('button', 'mm-step-btn', '+');
dec.addEventListener('click', () => {
const cur = parseInt(val.textContent, 10);
if (cur > 1) onCountChange(kind, cur - 1);
});
inc.addEventListener('click', () => {
const cur = parseInt(val.textContent, 10);
if (cur < max) onCountChange(kind, cur + 1);
});
row.appendChild(dec);
row.appendChild(val);
row.appendChild(inc);
row.appendChild(el('span', 'mm-step-range', `(1..${max})`));
if (kind === 'adsr') refs.adsrCountValue = val;
else refs.lfoCountValue = val;
return row;
};
section.appendChild(mkStepper('ADSR count', 'adsr', MAX_ADSR));
section.appendChild(mkStepper('LFO count', 'lfo', MAX_LFO));
body.appendChild(section);
}
function buildEnableSection(body, refs, onEnableChange) {
const section = el('div', 'mm-section');
section.appendChild(el('div', 'mm-section-header', 'Slot enables'));
const mkRow = (kind, max) => {
const wrap = el('div', 'mm-enable-wrap');
wrap.appendChild(el('div', 'mm-enable-label', kind.toUpperCase()));
const row = el('div', 'mm-enable-row');
for (let i = 0; i < max; i++) {
const b = el('button', `mm-enable-btn mm-enable-${kind}`, String(i + 1));
b.addEventListener('click', () => {
const enabled = !b.classList.contains('active');
onEnableChange(kind, i, enabled);
});
if (kind === 'adsr') refs.adsrEnables.push(b);
else refs.lfoEnables.push(b);
row.appendChild(b);
}
wrap.appendChild(row);
return wrap;
};
section.appendChild(mkRow('adsr', MAX_ADSR));
section.appendChild(mkRow('lfo', MAX_LFO));
body.appendChild(section);
}
function buildMatrixSection(body, refs, handlers) {
const section = el('div', 'mm-section mm-matrix-section');
const header = el('div', 'mm-section-header', 'Matrix');
// Phase E: the tap-vs-drag mode toggle stub was deleted — drag-to-paint
// is non-goal for Phase E and the button did nothing functional.
section.appendChild(header);
const legend = el('div', 'mm-hint',
'Click to cycle 0 → 0.25 → 0.5 → 0.75 → 1 → 0. ' +
'Shift-click: zero. Long-press: precise. Right-click: menu (negatives).');
section.appendChild(legend);
const scrollWrap = el('div', 'mm-scroll');
const grid = el('div', 'mm-grid');
refs.matrixGrid = grid;
scrollWrap.appendChild(grid);
section.appendChild(scrollWrap);
const empty = el('div', 'mm-hint warn', '');
refs.matrixEmpty = empty;
section.appendChild(empty);
body.appendChild(section);
}
function buildSoundParamSection(body, refs, onExposeChange) {
const section = el('div', 'mm-section');
section.appendChild(el('div', 'mm-section-header', 'Engine sound params'));
const list = el('div', 'mm-sound-list');
refs.soundParamList = list;
section.appendChild(list);
body.appendChild(section);
}
// ===========================================================================
// localStorage
// ===========================================================================
function loadUIState() {
const defaults = {
subEngine: 'subtractive',
adsrCount: 4,
lfoCount: 8,
adsrEnables: Array.from({ length: MAX_ADSR }, (_, i) => i < 4),
lfoEnables: Array.from({ length: MAX_LFO }, (_, i) => i < 8),
exposedEngineParams: [],
};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaults;
const s = JSON.parse(raw);
return {
subEngine: typeof s.subEngine === 'string' ? s.subEngine : defaults.subEngine,
adsrCount: clamp(s.adsrCount, 1, MAX_ADSR, defaults.adsrCount),
lfoCount: clamp(s.lfoCount, 1, MAX_LFO, defaults.lfoCount),
adsrEnables: padBool(s.adsrEnables, MAX_ADSR, defaults.adsrEnables),
lfoEnables: padBool(s.lfoEnables, MAX_LFO, defaults.lfoEnables),
exposedEngineParams: Array.isArray(s.exposedEngineParams) ? s.exposedEngineParams.slice() : [],
};
} catch (_) {
return defaults;
}
}
function saveUIState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(uiState));
onStateChange?.();
} catch (err) {
console.warn('[modular-ui] saveUIState failed:', err);
}
}
}
// ---------------------------------------------------------------------------
// DOM scaffolding
// ---------------------------------------------------------------------------
function createDockIcon() {
const dock = document.getElementById('dock');
if (!dock) throw new Error('[modular-ui] #dock not found');
// Insert before the help icon if present, else append.
const helpIcon = dock.querySelector('.dock-icon[data-drawer="help"]');
const btn = document.createElement('button');
btn.className = 'dock-icon hidden';
btn.dataset.drawer = 'modular';
btn.title = 'Modular mod pool & matrix';
btn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="3" height="3" rx="0.5"/>
<rect x="6.5" y="2" width="3" height="3" rx="0.5"/>
<rect x="11" y="2" width="3" height="3" rx="0.5"/>
<rect x="2" y="6.5" width="3" height="3" rx="0.5"/>
<rect x="6.5" y="6.5" width="3" height="3" rx="0.5"/>
<rect x="11" y="6.5" width="3" height="3" rx="0.5"/>
<rect x="2" y="11" width="3" height="3" rx="0.5"/>
<rect x="6.5" y="11" width="3" height="3" rx="0.5"/>
<rect x="11" y="11" width="3" height="3" rx="0.5"/>
</svg>
<span class="dock-label">Mod</span>
`;
if (helpIcon) dock.insertBefore(btn, helpIcon);
else dock.appendChild(btn);
return btn;
}
function createDrawer() {
const stack = document.getElementById('drawer-stack');
if (!stack) throw new Error('[modular-ui] #drawer-stack not found');
const root = document.createElement('div');
root.className = 'drawer hidden mm-drawer';
root.id = 'drawer-modular';
root.dataset.drawer = 'modular';
const header = document.createElement('div');
header.className = 'drawer-header';
const title = document.createElement('span');
title.textContent = 'Modular';
const closeBtn = document.createElement('button');
closeBtn.className = 'drawer-close';
closeBtn.dataset.drawer = 'modular';
closeBtn.innerHTML = '&times;';
header.appendChild(title);
header.appendChild(closeBtn);
const body = document.createElement('div');
body.className = 'drawer-body mm-body';
root.appendChild(header);
root.appendChild(body);
stack.appendChild(root);
return { root, header, body, closeBtn };
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
function injectStyles() {
if (document.getElementById('modular-ui-styles')) return;
const s = document.createElement('style');
s.id = 'modular-ui-styles';
s.textContent = `
.mm-drawer .mm-body {
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 10px 16px;
max-height: 85vh;
}
.mm-section {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border: 1px solid var(--glass-border, rgba(255,255,255,0.08));
border-radius: var(--radius-sm, 8px);
background: rgba(255,255,255,0.02);
}
.mm-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim, #888);
margin-bottom: 2px;
}
.mm-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.mm-label {
flex: 0 0 auto;
font-size: 0.75rem;
color: var(--text-dim, #888);
}
.mm-hint {
font-size: 0.65rem;
color: var(--text-dim, #888);
line-height: 1.3;
}
.mm-hint.warn {
color: #ffb070;
}
/* Sub-engine buttons */
.mm-sub-row { gap: 4px; }
.mm-sub-btn {
flex: 1;
padding: 8px 10px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--glass-border, rgba(255,255,255,0.1));
border-radius: 6px;
color: var(--text, #e0e0e0);
font-size: 0.75rem;
cursor: pointer;
min-height: 34px;
touch-action: manipulation;
}
.mm-sub-btn:hover:not(:disabled) { border-color: var(--accent, #ff6a00); }
.mm-sub-btn.active {
background: var(--accent-dim, rgba(255,106,0,0.25));
border-color: var(--accent, #ff6a00);
color: var(--accent, #ff6a00);
}
.mm-sub-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.mm-sub-btn.busy { opacity: 0.6; }
/* Steppers */
.mm-stepper-row {
display: flex;
align-items: center;
gap: 6px;
}
.mm-stepper-row .mm-label { flex: 1; }
.mm-step-btn {
width: 32px;
height: 32px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--glass-border, rgba(255,255,255,0.1));
border-radius: 6px;
color: var(--text, #e0e0e0);
cursor: pointer;
font-size: 1rem;
touch-action: manipulation;
}
.mm-step-btn:hover { border-color: var(--accent, #ff6a00); }
.mm-step-val {
display: inline-block;
min-width: 28px;
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.mm-step-range {
font-size: 0.65rem;
color: var(--text-dim, #888);
margin-left: 4px;
}
/* Enable rows */
.mm-enable-wrap {
display: flex;
flex-direction: column;
gap: 3px;
}
.mm-enable-label {
font-size: 0.6rem;
color: var(--text-dim, #888);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.mm-enable-row {
display: flex;
flex-wrap: wrap;
gap: 3px;
}
.mm-enable-btn {
width: 24px;
height: 24px;
font-size: 0.6rem;
background: rgba(255,255,255,0.04);
border: 1px solid var(--glass-border, rgba(255,255,255,0.1));
border-radius: 4px;
color: var(--text-dim, #888);
cursor: pointer;
padding: 0;
touch-action: manipulation;
}
.mm-enable-btn.active {
background: var(--accent-dim, rgba(255,106,0,0.25));
border-color: var(--accent, #ff6a00);
color: var(--accent, #ff6a00);
}
.mm-enable-btn.out-of-count {
opacity: 0.35;
}
/* Matrix grid */
.mm-matrix-section {
/* Allow this section to take extra vertical space */
}
.mm-mode-toggle {
font-size: 0.6rem;
padding: 2px 6px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--glass-border, rgba(255,255,255,0.1));
border-radius: 4px;
color: var(--text-dim, #888);
cursor: pointer;
touch-action: manipulation;
}
.mm-scroll {
overflow-x: auto;
overflow-y: auto;
max-height: 360px;
border-radius: 4px;
border: 1px solid var(--glass-border, rgba(255,255,255,0.06));
}
.mm-grid {
display: grid;
gap: 1px;
background: rgba(255,255,255,0.05);
padding: 1px;
min-width: max-content;
touch-action: manipulation;
}
.mm-corner,
.mm-col-header,
.mm-row-header {
background: #0d0d0d;
color: var(--text-dim, #888);
font-size: 0.55rem;
display: flex;
align-items: center;
justify-content: center;
padding: 2px 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
position: sticky;
z-index: 2;
}
.mm-col-header {
top: 0;
writing-mode: vertical-rl;
transform: rotate(180deg);
min-height: 48px;
min-width: 28px;
}
.mm-row-header {
left: 0;
min-width: 32px;
font-weight: 500;
}
.mm-row-header.mm-src-adsr { color: #ffb070; }
.mm-row-header.mm-src-lfo { color: #6bb6ff; }
.mm-corner {
top: 0;
left: 0;
z-index: 3;
}
.mm-cell {
background: rgba(255,255,255,0.04);
cursor: pointer;
font-size: 0.55rem;
font-variant-numeric: tabular-nums;
display: flex;
align-items: center;
justify-content: center;
min-height: 32px;
min-width: 28px;
user-select: none;
color: #fff;
transition: background 120ms ease;
}
.mm-cell:hover:not(.unavailable) {
outline: 1px solid rgba(255,255,255,0.25);
}
.mm-cell.unavailable {
cursor: not-allowed;
background: repeating-linear-gradient(
45deg,
rgba(255,255,255,0.02),
rgba(255,255,255,0.02) 4px,
rgba(255,255,255,0.05) 4px,
rgba(255,255,255,0.05) 8px
);
}
/* Context menu */
.mm-cell-menu {
position: fixed;
background: #0d0d0d;
border: 1px solid var(--glass-border, rgba(255,255,255,0.15));
border-radius: 6px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
z-index: 9999;
min-width: 140px;
}
.mm-cell-menu button {
background: transparent;
border: none;
color: var(--text, #e0e0e0);
text-align: left;
padding: 6px 10px;
font-size: 0.7rem;
border-radius: 4px;
cursor: pointer;
touch-action: manipulation;
}
.mm-cell-menu button:hover {
background: var(--accent-dim, rgba(255,106,0,0.25));
color: var(--accent, #ff6a00);
}
/* Sound params list */
.mm-sound-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 260px;
overflow-y: auto;
}
.mm-sound-group {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim, #888);
margin-top: 6px;
padding-bottom: 2px;
border-bottom: 1px solid var(--glass-border, rgba(255,255,255,0.06));
}
.mm-sound-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
color: var(--text, #e0e0e0);
padding: 3px 2px;
cursor: pointer;
}
.mm-sound-row input[type="checkbox"] {
accent-color: var(--accent, #ff6a00);
width: 16px;
height: 16px;
}
/* Preset overlay (Phase E) */
.mm-preset-open-btn {
padding: 8px 12px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--glass-border, rgba(255,255,255,0.1));
border-radius: 6px;
color: var(--text, #e0e0e0);
cursor: pointer;
font-size: 0.75rem;
text-align: center;
touch-action: manipulation;
}
.mm-preset-open-btn:hover {
border-color: var(--accent, #ff6a00);
color: var(--accent, #ff6a00);
}
.mm-preset-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 16px;
}
.mm-preset-panel {
background: #0d0d0d;
border: 1px solid var(--glass-border, rgba(255,255,255,0.15));
border-radius: 10px;
width: min(420px, 100%);
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.7);
}
.mm-preset-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--glass-border, rgba(255,255,255,0.08));
}
.mm-preset-title {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text, #e0e0e0);
}
.mm-preset-close {
background: transparent;
border: none;
color: var(--text-dim, #888);
font-size: 1.3rem;
cursor: pointer;
padding: 0 6px;
}
.mm-preset-close:hover { color: var(--accent, #ff6a00); }
.mm-preset-list {
display: flex;
flex-direction: column;
gap: 1px;
background: rgba(255,255,255,0.04);
overflow-y: auto;
}
.mm-preset-item {
background: #0d0d0d;
border: none;
color: var(--text, #e0e0e0);
text-align: left;
padding: 10px 16px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
touch-action: manipulation;
}
.mm-preset-item:hover {
background: var(--accent-dim, rgba(255,106,0,0.15));
}
.mm-preset-item.busy { opacity: 0.5; }
.mm-preset-name {
font-size: 0.85rem;
font-weight: 500;
color: var(--accent, #ff6a00);
}
.mm-preset-desc {
font-size: 0.7rem;
color: var(--text-dim, #888);
line-height: 1.3;
}
.mm-preset-sub {
font-size: 0.6rem;
color: var(--text-dim, #666);
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* Mobile tweaks */
@media (max-width: 640px) {
.mm-drawer .mm-body { padding: 6px 8px 12px; gap: 10px; }
.mm-cell { min-width: 26px; min-height: 28px; font-size: 0.5rem; }
.mm-scroll { max-height: 300px; }
}
`;
document.head.appendChild(s);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function el(tag, className, text) {
const e = document.createElement(tag);
if (className) e.className = className;
if (text != null) e.textContent = text;
return e;
}
function pad2(n) {
return String(n).padStart(2, '0');
}
function clamp(v, lo, hi, fallback) {
const n = typeof v === 'number' ? v : parseInt(v, 10);
if (!Number.isFinite(n)) return fallback;
return Math.max(lo, Math.min(hi, n));
}
function padBool(arr, len, fallback) {
const src = Array.isArray(arr) ? arr : fallback;
const out = new Array(len);
for (let i = 0; i < len; i++) out[i] = !!src[i];
return out;
}
function splitLabel(label) {
const i = label.lastIndexOf('/');
if (i < 0) return ['', label];
return [label.slice(0, i), label.slice(i + 1)];
}
function stripIndexPrefix(leaf) {
return leaf.replace(/^\d+_/, '');
}
function prettyGroup(group) {
// "1_Oscillators" → "Oscillators"
return group.replace(/^\d+_/, '').replace(/_/g, ' ');
}
function shortDest(name) {
// Truncate long destination names for column headers.
if (name.length <= 10) return name;
return name.slice(0, 9) + '…';
}