diff --git a/playground/js/shapeseq/chain-ui.js b/playground/js/shapeseq/chain-ui.js new file mode 100644 index 0000000..ba3d779 --- /dev/null +++ b/playground/js/shapeseq/chain-ui.js @@ -0,0 +1,1002 @@ +/** + * ShapeSeq Chain Builder UI — vertical stack of primitive cards + * + * Pedalboard-style chain editor: expandable cards with param sliders, + * drag-to-reorder, delete, add-primitive palette, generator combine toggle. + * Mobile-first, dark glass theme matching the immersive playground. + * + * All styles are inline (no external CSS dependency). + * + * @module shapeseq/chain-ui + */ + +import { PRIMITIVE_REGISTRY } from './primitives.js'; + +// ── Category colors ────────────────────────────────────────────────── + +const CATEGORY_COLORS = { + generator: '#ff6a00', + processor: '#00ccff', + timing: '#ffcc00', + converter: '#88ff00', +}; + +// ── Inject styles once ─────────────────────────────────────────────── + +let stylesInjected = false; + +function injectStyles() { + if (stylesInjected) return; + stylesInjected = true; + + const css = ` +/* ── Chain Builder container ─────────────────────────────────── */ + +.chain-builder { + --cb-glass-bg: rgba(13, 13, 13, 0.72); + --cb-glass-border: rgba(255, 255, 255, 0.08); + --cb-glass-blur: 16px; + --cb-text: #e0e0e0; + --cb-text-dim: #888; + --cb-radius: 12px; + --cb-radius-sm: 8px; + --cb-accent: #ff6a00; + + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 13px; + color: var(--cb-text); +} + +/* ── Primitive card ──────────────────────────────────────────── */ + +.chain-card { + position: relative; + background: var(--cb-glass-bg); + backdrop-filter: blur(var(--cb-glass-blur)); + -webkit-backdrop-filter: blur(var(--cb-glass-blur)); + border: 1px solid var(--cb-glass-border); + border-radius: var(--cb-radius); + overflow: hidden; + transition: box-shadow 0.2s, transform 0.15s; +} + +.chain-card.dragging { + opacity: 0.7; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + transform: scale(1.03); + z-index: 100; +} + +.chain-card-left-border { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + border-radius: var(--cb-radius) 0 0 var(--cb-radius); +} + +/* ── Card header ─────────────────────────────────────────────── */ + +.chain-card-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 10px 10px 14px; + cursor: pointer; + user-select: none; + -webkit-user-select: none; +} + +.chain-card-drag { + display: flex; + flex-direction: column; + gap: 2px; + cursor: grab; + touch-action: none; + padding: 4px 2px; + opacity: 0.4; + transition: opacity 0.15s; +} + +.chain-card-drag:hover, +.chain-card-drag:active { + opacity: 0.8; +} + +.chain-card-drag span { + display: block; + width: 14px; + height: 2px; + background: var(--cb-text); + border-radius: 1px; +} + +.chain-card-name { + flex: 1; + font-weight: 600; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chain-card-badge { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 6px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + color: var(--cb-text-dim); + white-space: nowrap; +} + +.chain-card-summary { + font-size: 11px; + color: var(--cb-text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 140px; +} + +.chain-card-delete { + background: none; + border: none; + color: var(--cb-text-dim); + font-size: 16px; + padding: 4px 6px; + cursor: pointer; + border-radius: 4px; + transition: color 0.15s, background 0.15s; + line-height: 1; +} + +.chain-card-delete:hover, +.chain-card-delete:active { + color: #ff4466; + background: rgba(255, 68, 102, 0.12); +} + +.chain-card-expand-icon { + font-size: 10px; + color: var(--cb-text-dim); + transition: transform 0.2s; +} + +.chain-card.expanded .chain-card-expand-icon { + transform: rotate(90deg); +} + +/* ── Card body (params) ──────────────────────────────────────── */ + +.chain-card-body { + max-height: 0; + overflow: hidden; + transition: max-height 0.25s ease; +} + +.chain-card.expanded .chain-card-body { + max-height: 600px; +} + +.chain-card-params { + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px 14px 12px 14px; +} + +/* ── Param slider row ────────────────────────────────────────── */ + +.chain-param-row { + display: flex; + align-items: center; + gap: 8px; +} + +.chain-param-label { + font-size: 11px; + color: var(--cb-text-dim); + min-width: 80px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chain-param-slider { + flex: 1; + -webkit-appearance: none; + appearance: none; + height: 6px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.08); + outline: none; + cursor: pointer; +} + +.chain-param-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--cb-text); + border: 2px solid rgba(0, 0, 0, 0.3); + cursor: pointer; +} + +.chain-param-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--cb-text); + border: 2px solid rgba(0, 0, 0, 0.3); + cursor: pointer; +} + +.chain-param-value { + font-size: 11px; + color: var(--cb-text-dim); + min-width: 32px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* ── NISPS live indicator ────────────────────────────────────── */ + +.chain-param-row.nisps-live .chain-param-slider { + box-shadow: 0 0 0 2px rgba(0, 140, 255, 0.5); + animation: nisps-pulse 1.5s ease-in-out infinite; +} + +@keyframes nisps-pulse { + 0%, 100% { box-shadow: 0 0 0 2px rgba(0, 140, 255, 0.3); } + 50% { box-shadow: 0 0 0 3px rgba(0, 140, 255, 0.7); } +} + +/* ── Add button ──────────────────────────────────────────────── */ + +.chain-add-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 12px; + border: 2px dashed rgba(255, 255, 255, 0.12); + border-radius: var(--cb-radius); + background: transparent; + color: var(--cb-text-dim); + font-family: inherit; + font-size: 13px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.chain-add-btn:hover, +.chain-add-btn:active { + border-color: var(--cb-accent); + color: var(--cb-accent); +} + +/* ── Generator combine mode toggle ───────────────────────────── */ + +.chain-combine-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 0 4px; +} + +.chain-combine-label { + font-size: 11px; + color: var(--cb-text-dim); +} + +.chain-combine-btn { + font-family: inherit; + font-size: 12px; + font-weight: 600; + padding: 4px 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: var(--cb-text); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + min-width: 60px; + text-align: center; +} + +.chain-combine-btn:hover, +.chain-combine-btn:active { + border-color: var(--cb-accent); + background: rgba(255, 106, 0, 0.1); +} + +/* ── Palette modal ───────────────────────────────────────────── */ + +.chain-palette-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: flex-end; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + animation: palette-fade-in 0.15s ease; +} + +@keyframes palette-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.chain-palette { + width: 100%; + max-width: 420px; + max-height: 80vh; + overflow-y: auto; + background: rgba(20, 20, 20, 0.95); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px 16px 0 0; + padding: 16px 12px 24px; + animation: palette-slide-up 0.2s ease; +} + +@keyframes palette-slide-up { + from { transform: translateY(40px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.chain-palette-title { + font-size: 14px; + font-weight: 600; + color: var(--cb-text, #e0e0e0); + text-align: center; + margin-bottom: 12px; +} + +.chain-palette-item { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border-radius: 10px; + cursor: pointer; + transition: background 0.12s; + border: none; + background: none; + width: 100%; + text-align: left; + font-family: inherit; + color: #e0e0e0; +} + +.chain-palette-item:hover, +.chain-palette-item:active { + background: rgba(255, 255, 255, 0.06); +} + +.chain-palette-item-color { + width: 4px; + height: 36px; + border-radius: 2px; + flex-shrink: 0; +} + +.chain-palette-item-info { + flex: 1; + min-width: 0; +} + +.chain-palette-item-name { + font-size: 13px; + font-weight: 600; +} + +.chain-palette-item-meta { + font-size: 11px; + color: #888; + margin-top: 2px; +} + +/* ── Drop indicator ──────────────────────────────────────────── */ + +.chain-drop-indicator { + height: 3px; + background: var(--cb-accent, #ff6a00); + border-radius: 2px; + margin: -2px 8px; + opacity: 0; + transition: opacity 0.1s; +} + +.chain-drop-indicator.active { + opacity: 1; +} +`; + + const style = document.createElement('style'); + style.textContent = css; + document.head.appendChild(style); +} + +// ── ChainBuilderUI ─────────────────────────────────────────────────── + +export class ChainBuilderUI { + /** + * @param {{ container: HTMLElement, chain: import('./chain.js').Chain, eventBus: { emit: Function, on: Function } }} opts + */ + constructor({ container, chain, eventBus }) { + injectStyles(); + + /** @type {HTMLElement} */ + this._container = container; + /** @type {import('./chain.js').Chain} */ + this._chain = chain; + /** @type {{ emit: Function, on: Function }} */ + this._bus = eventBus; + + /** @private @type {Set} flat param indices controlled by NISPS */ + this._liveParams = new Set(); + + /** @private @type {Function|null} */ + this._paramChangeCb = null; + + /** @private @type {Set} indices of expanded cards */ + this._expandedCards = new Set(); + + /** @private @type {Float32Array|null} current param values for display */ + this._currentParams = null; + + // ── Drag state ── + /** @private */ + this._dragIndex = -1; + /** @private */ + this._dragEl = null; + /** @private */ + this._dragStartY = 0; + /** @private */ + this._dragOffsetY = 0; + /** @private @type {HTMLElement|null} */ + this._cardList = null; + + // ── Palette overlay ref ── + /** @private @type {HTMLElement|null} */ + this._paletteOverlay = null; + + // ── Bound handlers for cleanup ── + this._onPointerMove = this._handlePointerMove.bind(this); + this._onPointerUp = this._handlePointerUp.bind(this); + + this.render(); + } + + // ── Public API ───────────────────────────────────────────────────── + + /** + * Rebuild the entire UI from current chain state. + */ + render() { + this._container.innerHTML = ''; + this._container.classList.add('chain-builder'); + + const primitives = this._chain.getPrimitives(); + + // Card list wrapper + const cardList = document.createElement('div'); + cardList.style.display = 'flex'; + cardList.style.flexDirection = 'column'; + cardList.style.gap = '6px'; + this._cardList = cardList; + + // Compute flat param offset for each primitive + let flatOffset = 0; + for (let i = 0; i < primitives.length; i++) { + const prim = primitives[i]; + const card = this._createCard(prim, i, flatOffset); + cardList.appendChild(card); + flatOffset += prim.paramCount; + } + + this._container.appendChild(cardList); + + // Add button + const addBtn = document.createElement('button'); + addBtn.className = 'chain-add-btn'; + addBtn.innerHTML = '+ Add Primitive'; + addBtn.addEventListener('click', () => this._openPalette()); + this._container.appendChild(addBtn); + + // Generator combine mode toggle + const toggle = this._createCombineToggle(); + this._container.appendChild(toggle); + } + + /** + * Update which flat param indices are NISPS-controlled (pulsing indicator). + * @param {Array} paramIndices + */ + setLiveParams(paramIndices) { + this._liveParams = new Set(paramIndices); + // Update existing rows without full re-render + const rows = this._container.querySelectorAll('.chain-param-row'); + rows.forEach((row) => { + const idx = parseInt(row.dataset.flatIndex, 10); + if (isNaN(idx)) return; + if (this._liveParams.has(idx)) { + row.classList.add('nisps-live'); + } else { + row.classList.remove('nisps-live'); + } + }); + } + + /** + * Register a callback for manual slider changes. + * @param {(flatParamIndex: number, value: number) => void} callback + */ + onParamChange(callback) { + this._paramChangeCb = callback; + } + + /** + * Update displayed param values without re-rendering. + * @param {Float32Array|Array} params - flat param array + */ + updateParamValues(params) { + this._currentParams = params; + const sliders = this._container.querySelectorAll('.chain-param-slider'); + sliders.forEach((slider) => { + const idx = parseInt(slider.dataset.flatIndex, 10); + if (!isNaN(idx) && idx < params.length) { + slider.value = params[idx]; + // Update adjacent value display + const valEl = slider.parentElement && slider.parentElement.querySelector('.chain-param-value'); + if (valEl) { + valEl.textContent = params[idx].toFixed(2); + } + } + }); + } + + /** + * Cleanup all DOM and listeners. + */ + destroy() { + this._closePalette(); + document.removeEventListener('pointermove', this._onPointerMove); + document.removeEventListener('pointerup', this._onPointerUp); + this._container.innerHTML = ''; + this._container.classList.remove('chain-builder'); + this._paramChangeCb = null; + } + + // ── Card creation ────────────────────────────────────────────────── + + /** + * @private + * @param {import('./primitive.js').Primitive} prim + * @param {number} chainIndex + * @param {number} flatOffset - flat param offset for this primitive + * @returns {HTMLElement} + */ + _createCard(prim, chainIndex, flatOffset) { + const card = document.createElement('div'); + card.className = 'chain-card'; + card.dataset.chainIndex = chainIndex; + if (this._expandedCards.has(chainIndex)) { + card.classList.add('expanded'); + } + + const catColor = CATEGORY_COLORS[prim.category] || '#888'; + + // Left color border + const leftBorder = document.createElement('div'); + leftBorder.className = 'chain-card-left-border'; + leftBorder.style.background = catColor; + card.appendChild(leftBorder); + + // Header + const header = document.createElement('div'); + header.className = 'chain-card-header'; + + // Drag handle + const drag = document.createElement('div'); + drag.className = 'chain-card-drag'; + drag.innerHTML = ''; + drag.addEventListener('pointerdown', (e) => this._handleDragStart(e, chainIndex, card)); + header.appendChild(drag); + + // Name + const name = document.createElement('span'); + name.className = 'chain-card-name'; + name.textContent = prim.name; + header.appendChild(name); + + // Category badge + const badge = document.createElement('span'); + badge.className = 'chain-card-badge'; + badge.textContent = prim.category; + badge.style.color = catColor; + header.appendChild(badge); + + // Summary (collapsed key params) + const summary = document.createElement('span'); + summary.className = 'chain-card-summary'; + summary.textContent = this._buildSummary(prim, flatOffset); + header.appendChild(summary); + + // Expand chevron + const chevron = document.createElement('span'); + chevron.className = 'chain-card-expand-icon'; + chevron.textContent = '\u25B6'; // right-pointing triangle + header.appendChild(chevron); + + // Delete button + const del = document.createElement('button'); + del.className = 'chain-card-delete'; + del.textContent = '\u00D7'; // multiplication sign as X + del.title = 'Remove'; + del.addEventListener('click', (e) => { + e.stopPropagation(); + this._removePrimitive(chainIndex); + }); + header.appendChild(del); + + // Toggle expand on header click (but not on drag handle or delete) + header.addEventListener('click', (e) => { + if (e.target.closest('.chain-card-drag') || e.target.closest('.chain-card-delete')) return; + this._toggleExpand(chainIndex, card); + }); + + card.appendChild(header); + + // Body (params) + const body = document.createElement('div'); + body.className = 'chain-card-body'; + + const paramsDiv = document.createElement('div'); + paramsDiv.className = 'chain-card-params'; + + for (let pi = 0; pi < prim.paramSchema.length; pi++) { + const schema = prim.paramSchema[pi]; + const flatIdx = flatOffset + pi; + const row = this._createParamRow(schema, flatIdx, prim); + paramsDiv.appendChild(row); + } + + body.appendChild(paramsDiv); + card.appendChild(body); + + return card; + } + + /** + * @private + */ + _buildSummary(prim, flatOffset) { + const schema = prim.paramSchema; + const maxShow = Math.min(3, schema.length); + const parts = []; + for (let i = 0; i < maxShow; i++) { + const val = this._currentParams + ? this._currentParams[flatOffset + i] + : schema[i].default; + parts.push(schema[i].name + ':' + (val !== undefined ? val.toFixed(2) : schema[i].default.toFixed(2))); + } + return parts.join(' '); + } + + /** + * @private + */ + _createParamRow(schema, flatIdx, prim) { + const row = document.createElement('div'); + row.className = 'chain-param-row'; + row.dataset.flatIndex = flatIdx; + if (this._liveParams.has(flatIdx)) { + row.classList.add('nisps-live'); + } + + const label = document.createElement('span'); + label.className = 'chain-param-label'; + label.textContent = schema.name; + row.appendChild(label); + + const slider = document.createElement('input'); + slider.type = 'range'; + slider.className = 'chain-param-slider'; + slider.min = '0'; + slider.max = '1'; + slider.step = '0.001'; + slider.dataset.flatIndex = flatIdx; + + const currentVal = this._currentParams + ? this._currentParams[flatIdx] + : schema.default; + slider.value = currentVal !== undefined ? currentVal : schema.default; + + const valDisplay = document.createElement('span'); + valDisplay.className = 'chain-param-value'; + valDisplay.textContent = parseFloat(slider.value).toFixed(2); + + slider.addEventListener('input', () => { + const v = parseFloat(slider.value); + valDisplay.textContent = v.toFixed(2); + if (this._paramChangeCb) { + this._paramChangeCb(flatIdx, v); + } + }); + + row.appendChild(slider); + row.appendChild(valDisplay); + + return row; + } + + // ── Expand / collapse ────────────────────────────────────────────── + + /** + * @private + */ + _toggleExpand(chainIndex, cardEl) { + if (this._expandedCards.has(chainIndex)) { + this._expandedCards.delete(chainIndex); + cardEl.classList.remove('expanded'); + } else { + this._expandedCards.add(chainIndex); + cardEl.classList.add('expanded'); + } + } + + // ── Add / remove primitives ──────────────────────────────────────── + + /** + * @private + */ + _removePrimitive(chainIndex) { + this._chain.removePrimitive(chainIndex); + // Adjust expanded set + const newExpanded = new Set(); + for (const idx of this._expandedCards) { + if (idx < chainIndex) newExpanded.add(idx); + else if (idx > chainIndex) newExpanded.add(idx - 1); + // idx === chainIndex is removed + } + this._expandedCards = newExpanded; + this.render(); + this._bus.emit('ui.chainEdit', { action: 'remove', index: chainIndex }); + } + + /** + * @private + */ + _addPrimitive(registryKey) { + const Ctor = PRIMITIVE_REGISTRY[registryKey]; + if (!Ctor) return; + const instance = new Ctor(); + this._chain.addPrimitive(instance); + this.render(); + this._bus.emit('ui.chainEdit', { action: 'add', name: registryKey }); + } + + // ── Palette ──────────────────────────────────────────────────────── + + /** + * @private + */ + _openPalette() { + if (this._paletteOverlay) return; + + const overlay = document.createElement('div'); + overlay.className = 'chain-palette-overlay'; + + const palette = document.createElement('div'); + palette.className = 'chain-palette'; + + const title = document.createElement('div'); + title.className = 'chain-palette-title'; + title.textContent = 'Add Primitive'; + palette.appendChild(title); + + const keys = Object.keys(PRIMITIVE_REGISTRY); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const CtorRef = PRIMITIVE_REGISTRY[key]; + // Instantiate temporarily to read metadata + const temp = new CtorRef(); + + const item = document.createElement('button'); + item.className = 'chain-palette-item'; + + const colorBar = document.createElement('div'); + colorBar.className = 'chain-palette-item-color'; + colorBar.style.background = CATEGORY_COLORS[temp.category] || '#888'; + item.appendChild(colorBar); + + const info = document.createElement('div'); + info.className = 'chain-palette-item-info'; + + const nameEl = document.createElement('div'); + nameEl.className = 'chain-palette-item-name'; + nameEl.textContent = temp.name; + info.appendChild(nameEl); + + const meta = document.createElement('div'); + meta.className = 'chain-palette-item-meta'; + meta.textContent = temp.category + ' \u00B7 ' + temp.paramCount + ' params'; + info.appendChild(meta); + + item.appendChild(info); + + item.addEventListener('click', () => { + this._closePalette(); + this._addPrimitive(key); + }); + + palette.appendChild(item); + } + + overlay.appendChild(palette); + + // Close on overlay click (outside palette) + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + this._closePalette(); + } + }); + + document.body.appendChild(overlay); + this._paletteOverlay = overlay; + } + + /** + * @private + */ + _closePalette() { + if (this._paletteOverlay) { + this._paletteOverlay.remove(); + this._paletteOverlay = null; + } + } + + // ── Generator combine mode toggle ────────────────────────────────── + + /** + * @private + */ + _createCombineToggle() { + const wrapper = document.createElement('div'); + wrapper.className = 'chain-combine-toggle'; + + const label = document.createElement('span'); + label.className = 'chain-combine-label'; + label.textContent = 'Gen combine:'; + wrapper.appendChild(label); + + const btn = document.createElement('button'); + btn.className = 'chain-combine-btn'; + btn.textContent = this._chain.generatorCombineMode === 'additive' ? 'Add' : 'Mul'; + + btn.addEventListener('click', () => { + if (this._chain.generatorCombineMode === 'additive') { + this._chain.generatorCombineMode = 'multiplicative'; + btn.textContent = 'Mul'; + } else { + this._chain.generatorCombineMode = 'additive'; + btn.textContent = 'Add'; + } + this._bus.emit('ui.chainEdit', { action: 'combineMode', mode: this._chain.generatorCombineMode }); + }); + + wrapper.appendChild(btn); + return wrapper; + } + + // ── Drag-to-reorder (pointer events) ─────────────────────────────── + + /** + * @private + */ + _handleDragStart(e, chainIndex, cardEl) { + e.preventDefault(); + e.stopPropagation(); + + this._dragIndex = chainIndex; + this._dragEl = cardEl; + this._dragStartY = e.clientY; + this._dragOffsetY = 0; + + cardEl.classList.add('dragging'); + cardEl.setPointerCapture(e.pointerId); + + document.addEventListener('pointermove', this._onPointerMove); + document.addEventListener('pointerup', this._onPointerUp); + } + + /** + * @private + */ + _handlePointerMove(e) { + if (this._dragIndex < 0 || !this._dragEl || !this._cardList) return; + + const dy = e.clientY - this._dragStartY; + this._dragOffsetY = dy; + this._dragEl.style.transform = 'translateY(' + dy + 'px) scale(1.03)'; + + // Determine drop target by checking which card we're over + const cards = this._cardList.querySelectorAll('.chain-card'); + const dragRect = this._dragEl.getBoundingClientRect(); + const dragCenter = dragRect.top + dragRect.height / 2; + + let targetIndex = this._dragIndex; + for (let i = 0; i < cards.length; i++) { + if (i === this._dragIndex) continue; + const rect = cards[i].getBoundingClientRect(); + const center = rect.top + rect.height / 2; + if (this._dragIndex < i && dragCenter > center) { + targetIndex = i; + } else if (this._dragIndex > i && dragCenter < center) { + targetIndex = i; + } + } + + this._dragTargetIndex = targetIndex; + } + + /** + * @private + */ + _handlePointerUp(e) { + document.removeEventListener('pointermove', this._onPointerMove); + document.removeEventListener('pointerup', this._onPointerUp); + + if (this._dragEl) { + this._dragEl.classList.remove('dragging'); + this._dragEl.style.transform = ''; + } + + const from = this._dragIndex; + const to = this._dragTargetIndex !== undefined ? this._dragTargetIndex : from; + + this._dragIndex = -1; + this._dragEl = null; + this._dragTargetIndex = undefined; + + if (from !== to && from >= 0) { + // Adjust expanded set for the move + const newExpanded = new Set(); + for (const idx of this._expandedCards) { + if (idx === from) { + newExpanded.add(to); + } else { + let adjusted = idx; + if (from < to) { + if (idx > from && idx <= to) adjusted = idx - 1; + } else { + if (idx >= to && idx < from) adjusted = idx + 1; + } + newExpanded.add(adjusted); + } + } + this._expandedCards = newExpanded; + + this._chain.movePrimitive(from, to); + this.render(); + this._bus.emit('ui.chainEdit', { action: 'reorder', from: from, to: to }); + } + } +} diff --git a/playground/js/shapeseq/sequencer.js b/playground/js/shapeseq/sequencer.js new file mode 100644 index 0000000..31d1d84 --- /dev/null +++ b/playground/js/shapeseq/sequencer.js @@ -0,0 +1,304 @@ +/** + * ShapeSeq Engine — central orchestrator + * + * Wires together the sequence MLP, param mapping, primitive chain, + * projection layer, clock engine, and C15 bridge. + * + * Main loop (triggered by setSequenceInputs): + * 1. Forward inputs to sequenceIML + * 2. Run MLP inference to get 16 outputs + * 3. Map 16 outputs to N primitive params via param-map + * 4. Evaluate the chain to produce a pattern description + * 5. Apply projection transforms + * 6. Schedule the pattern on the clock + * + * Bridge integration: + * - Subscribes to seq.noteOn / seq.noteOff on the event bus + * - Forwards to C15Bridge.noteOn / noteOff + * - Tracks active notes to avoid orphans + * + * @module shapeseq/sequencer + */ + +import { createSequenceIML, SEQ_N_OUTPUTS } from './seq-iml.js'; +import { Chain } from './chain.js'; +import { ClockEngine } from './clock.js'; +import { map } from './param-map.js'; +import { createProjectionChain, applyProjection, PRESETS } from './projection.js'; +import { SEQ } from './event-bus.js'; +import { + EuclideanRhythm, + ProbabilityGate, + PitchWalker, + IntervalLock, + VelocityShaper, +} from './primitives.js'; + +// ── Defaults ───────────────────────────────────────────────────────── + +const DEFAULT_BPM = 120; +const DEFAULT_STEP_COUNT = 8; +const DEFAULT_MASTER_SEED = 42; +const DEFAULT_SPREAD = 0.6; + +// ── ShapeSeqEngine ─────────────────────────────────────────────────── + +export class ShapeSeqEngine { + /** + * @param {{ audioContext: AudioContext, eventBus: import('./event-bus.js').EventBus, c15Bridge: import('../synth/c15-bridge.js').C15Bridge }} opts + */ + constructor({ audioContext, eventBus, c15Bridge }) { + if (!audioContext) throw new TypeError('ShapeSeqEngine requires an audioContext'); + if (!eventBus) throw new TypeError('ShapeSeqEngine requires an eventBus'); + if (!c15Bridge) throw new TypeError('ShapeSeqEngine requires a c15Bridge'); + + /** @private */ this._audioCtx = audioContext; + /** @private */ this._bus = eventBus; + /** @private */ this._c15 = c15Bridge; + + /** @private */ this._sequenceIML = null; + /** @private */ this._chain = null; + /** @private */ this._clock = null; + /** @private */ this._projectionChain = null; + + /** @private */ this._stepCount = DEFAULT_STEP_COUNT; + /** @private */ this._masterSeed = DEFAULT_MASTER_SEED; + /** @private */ this._playing = false; + /** @private */ this._initialized = false; + + // Track active notes for orphan prevention + /** @private @type {Set} */ + this._activeNotes = new Set(); + + // Bound handlers for event bus (stored for cleanup) + /** @private */ + this._onNoteOn = (data) => this._handleNoteOn(data); + /** @private */ + this._onNoteOff = (data) => this._handleNoteOff(data); + } + + // ── Lifecycle ────────────────────────────────────────────────────── + + /** + * Initialize all subsystems: create sequence IML, default chain, + * clock, and projection chain. Must be called before start(). + */ + async init() { + // 1. Create the sequence MLP + this._sequenceIML = await createSequenceIML(); + + // Randomize weights with default spread + this._sequenceIML.drawWeights(DEFAULT_SPREAD); + + // 2. Create the default primitive chain + this._chain = new Chain(); + this._chain.addPrimitive(new EuclideanRhythm()); + this._chain.addPrimitive(new ProbabilityGate()); + this._chain.addPrimitive(new PitchWalker()); + this._chain.addPrimitive(new IntervalLock()); + this._chain.addPrimitive(new VelocityShaper()); + this._chain.setMasterSeed(this._masterSeed); + + // 3. Set up the clock + this._clock = new ClockEngine(this._audioCtx, this._bus); + this._clock.bpm = DEFAULT_BPM; + + // 4. Create default projection chain (expressive preset) + const result = createProjectionChain(PRESETS.expressive); + if (!result.valid) { + throw new Error('Default projection chain invalid: ' + result.error); + } + this._projectionChain = result; + + // 5. Subscribe to event bus for C15 bridge integration + this._bus.on(SEQ.NOTE_ON, this._onNoteOn); + this._bus.on(SEQ.NOTE_OFF, this._onNoteOff); + + this._initialized = true; + } + + /** + * Start the clock. Requires init() to have been called. + */ + start() { + if (!this._initialized) { + throw new Error('ShapeSeqEngine.start() called before init()'); + } + if (this._playing) return; + + this._playing = true; + this._clock.start(); + } + + /** + * Stop the clock and release all active notes. + */ + stop() { + if (!this._playing) return; + + this._playing = false; + this._clock.stop(); + this._releaseAllNotes(); + } + + /** + * Full cleanup: stop playback, unsubscribe from events, destroy IML. + */ + destroy() { + this.stop(); + + // Unsubscribe from event bus + this._bus.off(SEQ.NOTE_ON, this._onNoteOn); + this._bus.off(SEQ.NOTE_OFF, this._onNoteOff); + + // Destroy the sequence IML instance + if (this._sequenceIML) { + this._sequenceIML.destroy(); + this._sequenceIML = null; + } + + this._chain = null; + this._clock = null; + this._projectionChain = null; + this._initialized = false; + } + + // ── Configuration ────────────────────────────────────────────────── + + /** + * Update the clock tempo. + * @param {number} bpm + */ + setTempo(bpm) { + if (this._clock) { + this._clock.setTempo(bpm); + } + } + + /** + * Set the number of steps in the generated pattern. + * @param {number} count + */ + setStepCount(count) { + const c = Math.max(1, count | 0); + this._stepCount = c; + } + + /** + * Set the projection preset by name. + * @param {'expressive'|'percussive'|'fullRange'} presetName + */ + setProjectionPreset(presetName) { + const preset = PRESETS[presetName]; + if (!preset) { + throw new Error('Unknown projection preset: ' + presetName); + } + const result = createProjectionChain(preset); + if (!result.valid) { + throw new Error('Projection chain invalid: ' + result.error); + } + this._projectionChain = result; + } + + // ── Chain access (for UI binding) ────────────────────────────────── + + /** @returns {Chain} */ + getChain() { return this._chain; } + + /** @returns {ClockEngine} */ + getClock() { return this._clock; } + + /** @returns {WasmIML} */ + getSequenceIML() { return this._sequenceIML; } + + // ── Input routing ────────────────────────────────────────────────── + + /** + * Feed new input values to the sequence MLP and run the full pipeline: + * MLP inference -> param mapping -> chain evaluation -> projection -> clock scheduling. + * + * Call this each frame with the routed input values (e.g., [x, y]). + * + * @param {number[]} values - input array (typically [x, y]) + */ + setSequenceInputs(values) { + if (!this._initialized || !this._sequenceIML) return; + + // 1. Forward inputs to the sequence IML + this._sequenceIML.setInputs(values); + + // 2. Run MLP inference + this._sequenceIML.process(); + + // 3. Get the 16 MLP outputs + const mlpOutputs = this._sequenceIML.getOutputs(); + + // 4. Map 16 outputs to N primitive params + const paramCount = this._chain.totalParamCount; + const mappedParams = map(mlpOutputs, paramCount); + + // 5. Evaluate the chain to produce a pattern description + const patternDesc = this._chain.evaluate(mappedParams, this._stepCount, this._masterSeed); + + // 6. Apply projection transforms + const projectedPattern = applyProjection(this._projectionChain, patternDesc); + + // 7. Schedule the pattern on the clock + this._clock.schedulePattern(projectedPattern); + } + + // ── ML control ───────────────────────────────────────────────────── + + /** @returns {boolean} */ + get isPlaying() { + return this._playing; + } + + // ── Bridge integration (private) ─────────────────────────────────── + + /** + * Handle seq.noteOn events from the event bus. + * Converts [0,1] pitch to MIDI note number and forwards to C15. + * + * @private + * @param {Object} data - { pitch, velocity, stepIndex, time, accent, isSubdivision } + */ + _handleNoteOn(data) { + // pitch comes from the projection layer; after RangeMap it's already + // in MIDI note range (e.g., 48-84). Round to nearest integer. + const midiNote = Math.round(data.pitch) | 0; + const velocity = data.velocity; + + // Clamp to valid MIDI range + const note = midiNote < 0 ? 0 : midiNote > 127 ? 127 : midiNote; + const vel = velocity < 0 ? 0 : velocity > 1 ? 1 : velocity; + + this._c15.noteOn(note, vel); + this._activeNotes.add(note); + } + + /** + * Handle seq.noteOff events from the event bus. + * + * @private + * @param {Object} data - { pitch, velocity, stepIndex, time } + */ + _handleNoteOff(data) { + const midiNote = Math.round(data.pitch) | 0; + const note = midiNote < 0 ? 0 : midiNote > 127 ? 127 : midiNote; + + this._c15.noteOff(note); + this._activeNotes.delete(note); + } + + /** + * Release all currently active notes to avoid orphaned noteOns. + * @private + */ + _releaseAllNotes() { + for (const note of this._activeNotes) { + this._c15.noteOff(note); + } + this._activeNotes.clear(); + } +}