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 = '×';
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 = `
Mod
`;
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 = '×';
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) + '…';
}