diff --git a/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/chain-ui.js b/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/chain-ui.js new file mode 100644 index 0000000..ba3d779 --- /dev/null +++ b/.claude/worktrees/agent-ae87fe47/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/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/chain.js b/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/chain.js new file mode 100644 index 0000000..7e0f88d --- /dev/null +++ b/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/chain.js @@ -0,0 +1,319 @@ +/** + * ShapeSeq Chain — sequential pipeline runner with generator combination modes + * + * Evaluates an ordered list of primitives as a sequential pipeline: + * 1. Generators run first, combined via additive or multiplicative merge + * 2. Processors transform the pattern in chain order + * 3. Converters run in chain order + * 4. Timing modifiers annotate last + * + * Params are distributed flat across primitives in chain order. + * Each primitive gets a deterministic PRNG stream via fork(masterPRNG, index). + * + * Port-ready: explicit state, typed arrays, no closures in hot path. + * + * @module shapeseq/chain + */ + +import { createPattern, mergePatterns } from './pattern.js'; +import { createPRNG, fork } from './prng.js'; + +// ── Category execution order ──────────────────────────────────────── + +const PHASE_ORDER = ['generator', 'processor', 'converter', 'timing']; + +// ── Chain class ───────────────────────────────────────────────────── + +export class Chain { + constructor() { + /** @private @type {Array} */ + this._primitives = []; + + /** @type {'additive'|'multiplicative'} */ + this.generatorCombineMode = 'additive'; + + /** @private @type {number} */ + this._masterSeed = 0; + + /** + * Per-primitive state objects, indexed by position in chain. + * Populated after evaluate() calls; used for freeze support. + * @private @type {Array} + */ + this._primitiveStates = []; + } + + // ── Primitive management ──────────────────────────────────────── + + /** + * Append a primitive to the end of the chain. + * @param {import('./primitive.js').Primitive} primitive + */ + addPrimitive(primitive) { + this._primitives.push(primitive); + this._primitiveStates.push(primitive.getState()); + } + + /** + * Remove the primitive at the given index. + * @param {number} index + */ + removePrimitive(index) { + const idx = index | 0; + if (idx < 0 || idx >= this._primitives.length) { + throw new RangeError('removePrimitive: index ' + index + ' out of range [0, ' + (this._primitives.length - 1) + ']'); + } + this._primitives.splice(idx, 1); + this._primitiveStates.splice(idx, 1); + } + + /** + * Insert a primitive at the given index, shifting others right. + * @param {number} index + * @param {import('./primitive.js').Primitive} primitive + */ + insertPrimitive(index, primitive) { + const idx = index | 0; + if (idx < 0 || idx > this._primitives.length) { + throw new RangeError('insertPrimitive: index ' + index + ' out of range [0, ' + this._primitives.length + ']'); + } + this._primitives.splice(idx, 0, primitive); + this._primitiveStates.splice(idx, 0, primitive.getState()); + } + + /** + * Move a primitive from one position to another. + * @param {number} fromIndex + * @param {number} toIndex + */ + movePrimitive(fromIndex, toIndex) { + const from = fromIndex | 0; + const to = toIndex | 0; + const len = this._primitives.length; + if (from < 0 || from >= len) { + throw new RangeError('movePrimitive: fromIndex ' + fromIndex + ' out of range [0, ' + (len - 1) + ']'); + } + if (to < 0 || to >= len) { + throw new RangeError('movePrimitive: toIndex ' + toIndex + ' out of range [0, ' + (len - 1) + ']'); + } + + const [prim] = this._primitives.splice(from, 1); + const [state] = this._primitiveStates.splice(from, 1); + this._primitives.splice(to, 0, prim); + this._primitiveStates.splice(to, 0, state); + } + + /** + * Get the current list of primitives (shallow copy). + * @returns {Array} + */ + getPrimitives() { + return this._primitives.slice(); + } + + // ── Configuration ─────────────────────────────────────────────── + + /** + * Total parameter count across all primitives in the chain. + * @returns {number} + */ + get totalParamCount() { + let total = 0; + for (let i = 0; i < this._primitives.length; i++) { + total += this._primitives[i].paramCount; + } + return total; + } + + /** + * Get a flat list of all param schemas across all primitives, + * annotated with their primitive and param indices. + * + * @returns {Array<{ primitiveIndex: number, paramIndex: number, schema: Object }>} + */ + getParamSchemas() { + const result = []; + for (let pi = 0; pi < this._primitives.length; pi++) { + const prim = this._primitives[pi]; + const schema = prim.paramSchema; + for (let si = 0; si < schema.length; si++) { + result.push({ + primitiveIndex: pi, + paramIndex: si, + schema: schema[si], + }); + } + } + return result; + } + + // ── Evaluation ────────────────────────────────────────────────── + + /** + * Evaluate the chain, producing a pattern description. + * + * Pipeline order: + * 1. Generators — combined via generatorCombineMode + * 2. Processors — sequential transform + * 3. Converters — sequential transform + * 4. Timing modifiers — annotate last + * + * @param {Float32Array|Array} params - flat param array distributed across primitives + * @param {number} stepCount - number of steps in the output pattern + * @param {number} masterSeed - seed for the master PRNG + * @returns {{ steps: Array, stepCount: number, metadata: Object }} + */ + evaluate(params, stepCount, masterSeed) { + const primitives = this._primitives; + const primCount = primitives.length; + + // Create master PRNG from seed + const masterPRNG = createPRNG(masterSeed >>> 0); + + // ── Bucket primitives by category, preserving chain order ── + + /** @type {Array<{ index: number, prim: Object }>} */ + const generators = []; + const processors = []; + const converters = []; + const timingMods = []; + + for (let i = 0; i < primCount; i++) { + const entry = { index: i, prim: primitives[i] }; + switch (primitives[i].category) { + case 'generator': generators.push(entry); break; + case 'processor': processors.push(entry); break; + case 'converter': converters.push(entry); break; + case 'timing': timingMods.push(entry); break; + } + } + + // ── Compute param offsets per primitive ── + + const paramOffsets = new Array(primCount); + let offset = 0; + for (let i = 0; i < primCount; i++) { + paramOffsets[i] = offset; + offset += primitives[i].paramCount; + } + + // ── Helper: run a single primitive ── + + const self = this; + + function runPrimitive(entry, inputPattern) { + const idx = entry.index; + const prim = entry.prim; + const pOffset = paramOffsets[idx]; + const pCount = prim.paramCount; + + // Slice params for this primitive + const primParams = new Float32Array(pCount); + for (let p = 0; p < pCount; p++) { + primParams[p] = pOffset + p < params.length ? +params[pOffset + p] : prim.paramSchema[p].default; + } + + // Fork a deterministic PRNG for this primitive + const primRNG = fork(masterPRNG, idx); + + // Get current state + const state = self._primitiveStates[idx] || prim.getState(); + + // Process + const result = prim.process(primParams, inputPattern, state, primRNG); + + // Store updated state + self._primitiveStates[idx] = result.nextState; + + return result.patternDesc; + } + + // ── Phase 1: Generators ── + + let pattern; + + if (generators.length === 0) { + // Default pattern: all steps triggered + pattern = createPattern(stepCount); + for (let i = 0; i < stepCount; i++) { + pattern.steps[i].trigger = true; + } + } else if (generators.length === 1) { + // Single generator — no merge needed + pattern = runPrimitive(generators[0], createPattern(stepCount)); + } else { + // Multiple generators — run each, then merge + let merged = runPrimitive(generators[0], createPattern(stepCount)); + for (let g = 1; g < generators.length; g++) { + const next = runPrimitive(generators[g], createPattern(stepCount)); + merged = mergePatterns(merged, next, this.generatorCombineMode); + } + pattern = merged; + } + + // ── Phase 2: Processors ── + + for (let i = 0; i < processors.length; i++) { + pattern = runPrimitive(processors[i], pattern); + } + + // ── Phase 3: Converters ── + + for (let i = 0; i < converters.length; i++) { + pattern = runPrimitive(converters[i], pattern); + } + + // ── Phase 4: Timing modifiers ── + + for (let i = 0; i < timingMods.length; i++) { + pattern = runPrimitive(timingMods[i], pattern); + } + + return pattern; + } + + // ── State management (for freeze) ────────────────────────────── + + /** + * Get serializable state for all primitives in the chain. + * @returns {Array} + */ + getState() { + const states = new Array(this._primitives.length); + for (let i = 0; i < this._primitives.length; i++) { + states[i] = this._primitiveStates[i] || this._primitives[i].getState(); + } + return states; + } + + /** + * Restore all primitive states from a previously serialized state array. + * @param {Array} states + */ + setState(states) { + if (!Array.isArray(states)) { + throw new TypeError('setState expects an array of state objects'); + } + const len = Math.min(states.length, this._primitives.length); + for (let i = 0; i < len; i++) { + this._primitives[i].setState(states[i]); + this._primitiveStates[i] = states[i]; + } + } + + /** + * Get the master PRNG seed. + * @returns {number} + */ + getMasterSeed() { + return this._masterSeed; + } + + /** + * Set the master PRNG seed. + * @param {number} seed - 32-bit integer seed + */ + setMasterSeed(seed) { + this._masterSeed = seed >>> 0; + } +} diff --git a/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/primitive.js b/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/primitive.js new file mode 100644 index 0000000..ebec5b6 --- /dev/null +++ b/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/primitive.js @@ -0,0 +1,246 @@ +/** + * ShapeSeq Primitive Base Class and Param Schema System + * + * Base class for all sequencing primitives (generators, processors, + * timing modifiers, converters). Defines the param schema format, + * symbolic process() interface, and state management for freeze support. + * + * Port-ready: explicit state, no closures, typed arrays where possible. + * + * @module shapeseq/primitive + */ + +// ── Valid primitive categories ────────────────────────────────────── + +export const CATEGORIES = Object.freeze([ + 'generator', + 'processor', + 'timing', + 'converter', +]); + +// ── Param schema defaults ─────────────────────────────────────────── + +const DEFAULT_SCALED_RANGE = 0.3; + +// ── Boundary enforcement helpers ──────────────────────────────────── + +/** + * Clamp a value to [0, 1]. + * @param {number} v + * @returns {number} + */ +function clamp01(v) { + return v < 0 ? 0 : v > 1 ? 1 : v; +} + +/** + * Wrap a value into [0, 1) with modular arithmetic. + * @param {number} v + * @returns {number} + */ +function wrap01(v) { + const m = v % 1; + return m < 0 ? m + 1 : m; +} + +/** + * Apply boundary enforcement to a raw param value. + * + * @param {number} value - raw [0,1] value (or delta-adjusted value) + * @param {{ boundary: string, scaledRange?: number }} schema - param schema entry + * @param {number|null} frozenValue - frozen value for 'scaled' boundary (null if not frozen) + * @returns {number} + */ +export function applyBoundary(value, schema, frozenValue) { + switch (schema.boundary) { + case 'wrap': + return wrap01(value); + case 'scaled': { + if (frozenValue === null || frozenValue === undefined) { + return clamp01(value); + } + const range = schema.scaledRange !== undefined ? schema.scaledRange : DEFAULT_SCALED_RANGE; + const lo = frozenValue - range; + const hi = frozenValue + range; + // Map [0,1] input to [lo, hi], then clamp to [0,1] + const mapped = lo + value * (hi - lo); + return clamp01(mapped); + } + case 'clamp': + default: + return clamp01(value); + } +} + +// ── Param schema validation ───────────────────────────────────────── + +/** + * Validate a single param schema entry. + * Throws on invalid entries for fast fail during development. + * + * @param {{ name: string, default: number, boundary: string, scaledRange?: number }} entry + * @param {number} index - position in schema array (for error messages) + */ +function validateSchemaEntry(entry, index) { + if (!entry || typeof entry !== 'object') { + throw new TypeError('paramSchema[' + index + '] must be an object'); + } + if (typeof entry.name !== 'string' || entry.name.length === 0) { + throw new TypeError('paramSchema[' + index + '].name must be a non-empty string'); + } + if (typeof entry.default !== 'number' || entry.default < 0 || entry.default > 1) { + throw new RangeError('paramSchema[' + index + '].default must be in [0,1], got ' + entry.default); + } + if (entry.boundary !== 'clamp' && entry.boundary !== 'wrap' && entry.boundary !== 'scaled') { + throw new TypeError( + "paramSchema[" + index + "].boundary must be 'clamp', 'wrap', or 'scaled', got '" + entry.boundary + "'" + ); + } + if (entry.boundary === 'scaled') { + const sr = entry.scaledRange; + if (sr !== undefined && (typeof sr !== 'number' || sr <= 0 || sr > 1)) { + throw new RangeError('paramSchema[' + index + '].scaledRange must be in (0,1], got ' + sr); + } + } +} + +// ── Primitive base class ──────────────────────────────────────────── + +export class Primitive { + /** + * @param {string} name - unique identifier for this primitive type + * @param {string} category - one of CATEGORIES + * @param {Array<{ name: string, default: number, boundary: string, scaledRange?: number }>} paramSchema + */ + constructor(name, category, paramSchema) { + if (typeof name !== 'string' || name.length === 0) { + throw new TypeError('Primitive name must be a non-empty string'); + } + if (CATEGORIES.indexOf(category) === -1) { + throw new TypeError( + "Primitive category must be one of [" + CATEGORIES.join(', ') + "], got '" + category + "'" + ); + } + if (!Array.isArray(paramSchema)) { + throw new TypeError('paramSchema must be an array'); + } + + // Validate each entry + for (let i = 0; i < paramSchema.length; i++) { + validateSchemaEntry(paramSchema[i], i); + } + + /** @type {string} */ + this.name = name; + + /** @type {string} */ + this.category = category; + + /** + * Frozen copy of the param schema. Each entry: + * { name: string, default: number, boundary: 'clamp'|'wrap'|'scaled', scaledRange?: number } + * @type {Array} + */ + this.paramSchema = Object.freeze(paramSchema.map(function (entry) { + const frozen = { + name: entry.name, + default: entry.default, + boundary: entry.boundary, + }; + if (entry.boundary === 'scaled') { + frozen.scaledRange = entry.scaledRange !== undefined ? entry.scaledRange : DEFAULT_SCALED_RANGE; + } + return Object.freeze(frozen); + })); + + /** @private */ + this._seed = 0; + } + + // ── Param utilities ───────────────────────────────────────────── + + /** + * Total number of parameters this primitive exposes. + * @returns {number} + */ + get paramCount() { + return this.paramSchema.length; + } + + /** + * Get default param values as a Float32Array, one per schema entry. + * @returns {Float32Array} + */ + getDefaults() { + const count = this.paramSchema.length; + const defaults = new Float32Array(count); + for (let i = 0; i < count; i++) { + defaults[i] = this.paramSchema[i].default; + } + return defaults; + } + + // ── Symbolic processing ───────────────────────────────────────── + + /** + * Transform a pattern description. Subclasses MUST override this. + * + * - Generators ignore patternDesc and create a new one (using createPattern()) + * - Processors/timing modifiers clone and transform patternDesc + * - The rng param is a PRNG state from prng.js; consume via next(rng) + * and return the consumed state in the result + * + * @param {Float32Array|Array} params - param values, one per schema entry, each [0,1] + * @param {{ steps: Array, stepCount: number, metadata: Object }} patternDesc - input pattern + * @param {Object} state - primitive-specific state (from previous process() call or getState()) + * @param {{ state: number }} rng - PRNG state object from prng.js + * @returns {{ patternDesc: { steps: Array, stepCount: number, metadata: Object }, nextState: Object }} + */ + process(params, patternDesc, state, rng) { + void params; void patternDesc; void state; void rng; + throw new Error(this.name + '.process() must be overridden by subclass'); + } + + // ── State management (for freeze) ────────────────────────────── + + /** + * Get serializable state for this primitive. + * Stateless primitives return {}. Stateful primitives (e.g. Pitch Walker) + * override to include their internal state. + * + * @returns {Object} + */ + getState() { + return {}; + } + + /** + * Restore primitive state from a previously serialized state object. + * Stateless primitives are a no-op. Stateful primitives override. + * + * @param {Object} _state + */ + setState(_state) { + // no-op for stateless primitives + } + + /** + * Get the PRNG seed associated with this primitive. + * Used by freeze-as-algorithm to replay identical sequences. + * + * @returns {number} + */ + getSeed() { + return this._seed; + } + + /** + * Set the PRNG seed for this primitive. + * + * @param {number} seed - 32-bit integer seed + */ + setSeed(seed) { + this._seed = seed >>> 0; + } +} diff --git a/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/primitives.js b/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/primitives.js new file mode 100644 index 0000000..9e21cf8 --- /dev/null +++ b/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/primitives.js @@ -0,0 +1,505 @@ +/** + * ShapeSeq Sequencing Primitives + * + * All 8 primitives for the ShapeSeq generative sequencing system. + * Each extends Primitive and implements process(params, patternDesc, state, rng). + * + * Port-ready: explicit state, no closures, seeded PRNG, typed arrays. + * + * @module shapeseq/primitives + */ + +import { Primitive } from './primitive.js'; +import { createPattern, clonePattern, setStep } from './pattern.js'; +import { next, nextInt } from './prng.js'; + +// ── Helper: map [0,1] float to integer range [lo, hi] ────────────── + +function mapToInt(value, lo, hi) { + const clamped = value < 0 ? 0 : value > 1 ? 1 : value; + return lo + Math.floor(clamped * (hi - lo + 1 - 1e-9)); +} + +// ── 1. EuclideanRhythm ────────────────────────────────────────────── + +/** + * Bjorklund algorithm: distribute `pulses` as evenly as possible + * across `steps`, then apply rotation. + */ +function bjorklund(steps, pulses) { + if (pulses >= steps) { + const result = new Array(steps); + for (let i = 0; i < steps; i++) result[i] = true; + return result; + } + if (pulses <= 0) { + const result = new Array(steps); + for (let i = 0; i < steps; i++) result[i] = false; + return result; + } + + // Build pattern using Bjorklund's algorithm + let groups = []; + for (let i = 0; i < pulses; i++) groups.push([true]); + for (let i = 0; i < steps - pulses; i++) groups.push([false]); + + while (true) { + const remainder = groups.length - pulses; + if (remainder <= 1) break; + const minLen = Math.min(pulses, remainder); + const newGroups = []; + for (let i = 0; i < minLen; i++) { + newGroups.push(groups[i].concat(groups[groups.length - 1 - i])); + } + // Keep any leftovers + const leftStart = minLen; + const leftEnd = groups.length - minLen; + for (let i = leftStart; i < leftEnd; i++) { + newGroups.push(groups[i]); + } + groups = newGroups; + pulses = minLen; + if (pulses <= 1) break; + } + + // Flatten groups + const result = []; + for (let i = 0; i < groups.length; i++) { + for (let j = 0; j < groups[i].length; j++) { + result.push(groups[i][j]); + } + } + return result; +} + +export class EuclideanRhythm extends Primitive { + constructor() { + super('EuclideanRhythm', 'generator', [ + { name: 'steps', default: 0.5, boundary: 'clamp' }, + { name: 'pulses', default: 0.5, boundary: 'clamp' }, + { name: 'rotation', default: 0.0, boundary: 'wrap' }, + ]); + } + + process(params, patternDesc, state, rng) { + const stepCount = patternDesc.stepCount; + const steps = mapToInt(params[0], 2, stepCount); + const pulses = mapToInt(params[1], 0, steps); + const rotation = mapToInt(params[2], 0, steps - 1); + + const rhythm = bjorklund(steps, pulses); + const pattern = createPattern(stepCount); + + for (let i = 0; i < stepCount; i++) { + if (i < steps) { + const srcIdx = (i - rotation + steps) % steps; + if (rhythm[srcIdx]) { + setStep(pattern, i, { trigger: true }); + } + } + // Steps beyond `steps` remain untriggered (default) + } + + return { patternDesc: pattern, nextState: {} }; + } +} + +// ── 2. ProbabilityGate ────────────────────────────────────────────── + +export class ProbabilityGate extends Primitive { + constructor() { + super('ProbabilityGate', 'processor', [ + { name: 'density', default: 0.7, boundary: 'clamp' }, + { name: 'accentProbability', default: 0.3, boundary: 'clamp' }, + ]); + } + + process(params, patternDesc, state, rng) { + const density = params[0]; + const accentProb = params[1]; + const pattern = clonePattern(patternDesc); + let currentRng = rng; + + for (let i = 0; i < pattern.stepCount; i++) { + const step = pattern.steps[i]; + if (step.trigger) { + // Coin flip for survival + const r1 = next(currentRng); + currentRng = r1.nextState; + + if (r1.value >= density) { + step.trigger = false; + step.accent = false; + } else { + // Accent coin flip + const r2 = next(currentRng); + currentRng = r2.nextState; + step.accent = r2.value < accentProb; + } + } + } + + return { patternDesc: pattern, nextState: {} }; + } +} + +// ── 3. PitchWalker ────────────────────────────────────────────────── + +export class PitchWalker extends Primitive { + constructor() { + super('PitchWalker', 'generator', [ + { name: 'stepSize', default: 0.3, boundary: 'clamp' }, + { name: 'directionBias', default: 0.5, boundary: 'clamp' }, + { name: 'gravity', default: 0.3, boundary: 'clamp' }, + { name: 'range', default: 0.8, boundary: 'clamp' }, + ]); + + /** @private */ + this._position = 0.5; + } + + getState() { + return { position: this._position }; + } + + setState(savedState) { + if (savedState && typeof savedState.position === 'number') { + this._position = savedState.position; + } + } + + process(params, patternDesc, state, rng) { + const stepSize = params[0]; + const directionBias = params[1]; + const gravity = params[2]; + const range = params[3]; + + // Restore position from state if provided + let position = (state && typeof state.position === 'number') + ? state.position + : this._position; + + const pattern = createPattern(patternDesc.stepCount); + let currentRng = rng; + + // Use incoming pattern's triggers if available, otherwise all triggered + const srcSteps = patternDesc.steps; + + for (let i = 0; i < patternDesc.stepCount; i++) { + const triggered = srcSteps[i].trigger; + + if (triggered) { + // Random walk step + const r1 = next(currentRng); + currentRng = r1.nextState; + + // Direction: bias + gravity toward center + const gravityPull = (0.5 - position) * gravity; + const biasOffset = (directionBias - 0.5) * 2; // [-1, 1] + const direction = biasOffset + gravityPull; + + // Random component: [-1, 1] scaled by stepSize + const randomComponent = (r1.value * 2 - 1) * stepSize * range; + const delta = direction * stepSize * 0.5 + randomComponent; + + position = position + delta; + // Clamp to [0, 1] + if (position < 0) position = 0; + if (position > 1) position = 1; + + setStep(pattern, i, { trigger: true, pitch: position }); + } + // Untriggered steps keep default pitch, trigger=false + } + + this._position = position; + + return { + patternDesc: pattern, + nextState: { position: position }, + }; + } +} + +// ── 4. Ratchet ────────────────────────────────────────────────────── + +export class Ratchet extends Primitive { + constructor() { + super('Ratchet', 'timing', [ + { name: 'maxDivision', default: 0.5, boundary: 'clamp' }, + { name: 'probability', default: 0.5, boundary: 'clamp' }, + ]); + } + + process(params, patternDesc, state, rng) { + const maxDiv = mapToInt(params[0], 1, 4); + const probability = params[1]; + const pattern = clonePattern(patternDesc); + let currentRng = rng; + + for (let i = 0; i < pattern.stepCount; i++) { + const step = pattern.steps[i]; + if (step.trigger) { + const r1 = next(currentRng); + currentRng = r1.nextState; + + if (r1.value < probability && maxDiv > 1) { + // Pick a subdivision count in [2, maxDiv] + const r2 = nextInt(currentRng, 2, maxDiv); + currentRng = r2.nextState; + step.subdivisions = r2.value; + } + } + } + + return { patternDesc: pattern, nextState: {} }; + } +} + +// ── 5. SwingGroove ────────────────────────────────────────────────── + +export class SwingGroove extends Primitive { + constructor() { + super('SwingGroove', 'timing', [ + { name: 'swingAmount', default: 0.0, boundary: 'clamp' }, + { name: 'swingGrid', default: 0.0, boundary: 'clamp' }, + ]); + } + + process(params, patternDesc, state, rng) { + const swingAmount = params[0]; + const pattern = clonePattern(patternDesc); + + // Max swing = 0.33 (triplet feel) + const maxOffset = 0.33; + const offset = swingAmount * maxOffset; + + // Apply swing to every other step (odd-indexed steps) + for (let i = 1; i < pattern.stepCount; i += 2) { + pattern.steps[i].timeOffset = offset; + } + + return { patternDesc: pattern, nextState: {} }; + } +} + +// ── 6. DensityMorph ───────────────────────────────────────────────── + +export class DensityMorph extends Primitive { + constructor() { + super('DensityMorph', 'generator', [ + { name: 'density', default: 0.5, boundary: 'clamp' }, + { name: 'clustering', default: 0.0, boundary: 'clamp' }, + ]); + } + + process(params, patternDesc, state, rng) { + const density = params[0]; + const clustering = params[1]; + const stepCount = patternDesc.stepCount; + const pattern = createPattern(stepCount); + const numTriggers = Math.floor(density * stepCount); + + if (numTriggers <= 0) { + return { patternDesc: pattern, nextState: {} }; + } + if (numTriggers >= stepCount) { + for (let i = 0; i < stepCount; i++) { + setStep(pattern, i, { trigger: true }); + } + return { patternDesc: pattern, nextState: {} }; + } + + let currentRng = rng; + + if (clustering < 0.01) { + // Even spread: Euclidean-like placement + for (let i = 0; i < numTriggers; i++) { + const idx = Math.floor((i * stepCount) / numTriggers); + setStep(pattern, idx, { trigger: true }); + } + } else if (clustering > 0.99) { + // Full clustering: contiguous burst + const r1 = nextInt(currentRng, 0, stepCount - 1); + currentRng = r1.nextState; + const startPos = r1.value; + for (let i = 0; i < numTriggers; i++) { + const idx = (startPos + i) % stepCount; + setStep(pattern, idx, { trigger: true }); + } + } else { + // Interpolate: place triggers with clustering-dependent spread + // Use a "center of mass" approach: + // Pick a random center, then distribute triggers around it + // with spread inversely proportional to clustering + const r1 = next(currentRng); + currentRng = r1.nextState; + const center = r1.value * stepCount; + + // Spread factor: low clustering = large spread, high = tight + const spreadRadius = (1 - clustering) * stepCount * 0.5; + + // Score each step by distance from center (wrapping) + const scores = new Float32Array(stepCount); + for (let i = 0; i < stepCount; i++) { + // Wrapped distance from center + let dist = Math.abs(i - center); + if (dist > stepCount * 0.5) dist = stepCount - dist; + // Add small random jitter to break ties + const r2 = next(currentRng); + currentRng = r2.nextState; + scores[i] = dist / (spreadRadius + 0.001) + r2.value * 0.01; + } + + // Select the numTriggers steps with lowest scores + const indices = new Array(stepCount); + for (let i = 0; i < stepCount; i++) indices[i] = i; + indices.sort(function (a, b) { return scores[a] - scores[b]; }); + + for (let i = 0; i < numTriggers; i++) { + setStep(pattern, indices[i], { trigger: true }); + } + } + + return { patternDesc: pattern, nextState: {} }; + } +} + +// ── 7. IntervalLock ───────────────────────────────────────────────── + +const SCALES = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], // chromatic + [0, 2, 4, 5, 7, 9, 11], // major + [0, 2, 3, 5, 7, 8, 10], // natural minor + [0, 2, 3, 5, 7, 8, 11], // harmonic minor + [0, 2, 4, 7, 9], // pentatonic major + [0, 3, 5, 7, 10], // pentatonic minor + [0, 3, 5, 6, 7, 10], // blues + [0, 2, 3, 5, 7, 9, 10], // dorian + [0, 2, 4, 5, 7, 9, 10], // mixolydian + [0, 2, 4, 6, 8, 10], // whole tone + [0, 2, 3, 5, 6, 8, 9, 11], // diminished +]; + +export class IntervalLock extends Primitive { + constructor() { + super('IntervalLock', 'converter', [ + { name: 'root', default: 0.0, boundary: 'clamp' }, + { name: 'mode', default: 0.0, boundary: 'clamp' }, + { name: 'octaveRange', default: 0.25, boundary: 'clamp' }, + ]); + } + + process(params, patternDesc, state, rng) { + const root = mapToInt(params[0], 0, 11); + const scaleIdx = mapToInt(params[1], 0, SCALES.length - 1); + const octaveRange = mapToInt(params[2], 1, 4); + const scale = SCALES[scaleIdx]; + + const pattern = clonePattern(patternDesc); + + // Build the full set of MIDI notes in this scale + root + range + const notes = []; + for (let oct = 0; oct < octaveRange; oct++) { + for (let i = 0; i < scale.length; i++) { + const midiNote = root + scale[i] + oct * 12; + if (midiNote <= 127) { + notes.push(midiNote); + } + } + } + + if (notes.length === 0) { + return { patternDesc: pattern, nextState: {} }; + } + + for (let i = 0; i < pattern.stepCount; i++) { + const step = pattern.steps[i]; + // Quantize pitch [0,1] to nearest note in our scale + const targetIdx = Math.round(step.pitch * (notes.length - 1)); + const clampedIdx = targetIdx < 0 ? 0 : targetIdx >= notes.length ? notes.length - 1 : targetIdx; + // Store as MIDI note / 127 to stay in [0,1] + step.pitch = notes[clampedIdx] / 127; + } + + return { patternDesc: pattern, nextState: {} }; + } +} + +// ── 8. VelocityShaper ─────────────────────────────────────────────── + +export class VelocityShaper extends Primitive { + constructor() { + super('VelocityShaper', 'processor', [ + { name: 'curveType', default: 0.0, boundary: 'clamp' }, + { name: 'depth', default: 0.5, boundary: 'clamp' }, + { name: 'phase', default: 0.0, boundary: 'wrap' }, + ]); + } + + process(params, patternDesc, state, rng) { + const curveIdx = mapToInt(params[0], 0, 4); + const depth = params[1]; + const phase = params[2]; + const pattern = clonePattern(patternDesc); + const stepCount = pattern.stepCount; + let currentRng = rng; + + for (let i = 0; i < stepCount; i++) { + const step = pattern.steps[i]; + if (!step.trigger) continue; + + // Phase-shifted position + const pos = ((i / stepCount) + phase) % 1; + let shapeValue; + + switch (curveIdx) { + case 0: // flat + shapeValue = 1.0; + break; + case 1: // accent-every-N (accent every 4th step) + shapeValue = ((i + Math.floor(phase * stepCount)) % 4 === 0) ? 1.0 : 0.5; + break; + case 2: // crescendo + shapeValue = pos; + break; + case 3: // decrescendo + shapeValue = 1.0 - pos; + break; + case 4: { // random + const r1 = next(currentRng); + currentRng = r1.nextState; + shapeValue = r1.value; + break; + } + default: + shapeValue = 1.0; + } + + // Apply depth: interpolate between uniform (1.0) and shaped + // depth=0 means all same velocity (base), depth=1 means full shape + const baseVelocity = 0.7; + const shaped = shapeValue; + step.velocity = baseVelocity * (1 - depth) + shaped * depth; + + // Clamp + if (step.velocity < 0) step.velocity = 0; + if (step.velocity > 1) step.velocity = 1; + } + + return { patternDesc: pattern, nextState: {} }; + } +} + +// ── Registry ──────────────────────────────────────────────────────── + +export const PRIMITIVE_REGISTRY = { + EuclideanRhythm: EuclideanRhythm, + ProbabilityGate: ProbabilityGate, + PitchWalker: PitchWalker, + Ratchet: Ratchet, + SwingGroove: SwingGroove, + DensityMorph: DensityMorph, + IntervalLock: IntervalLock, + VelocityShaper: VelocityShaper, +}; diff --git a/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/sequencer.js b/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/sequencer.js new file mode 100644 index 0000000..31d1d84 --- /dev/null +++ b/.claude/worktrees/agent-ae87fe47/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(); + } +} diff --git a/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/step-viz.js b/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/step-viz.js new file mode 100644 index 0000000..8682c0c --- /dev/null +++ b/.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/step-viz.js @@ -0,0 +1,328 @@ +/** + * ShapeSeq Circular Step Visualizer + * + * Renders steps arranged in a circle (heptagon, tridecagon, etc.) + * with pitch mapped to radial distance, velocity to node size, + * and accent to color brightness. + * + * Designed for 60fps rendering — no allocations in the render loop. + * Port-ready: explicit state, no closures in hot paths. + * + * @module shapeseq/step-viz + */ + +import { SEQ } from './event-bus.js'; + +// ── Constants (pre-allocated, shared across instances) ────────────── + +const TWO_PI = Math.PI * 2; +const HALF_PI = Math.PI * 0.5; + +// Color constants +const COLOR_INACTIVE = 'rgba(255, 255, 255, 0.15)'; +const COLOR_ACTIVE = '#00ccff'; +const COLOR_CURRENT = '#ff6a00'; +const COLOR_ACCENT = '#ffcc00'; +const COLOR_BG = '#0d0d0d'; + +// Glow colors (pre-computed rgba strings) +const GLOW_ACTIVE = 'rgba(0, 204, 255, 0.3)'; +const GLOW_CURRENT = 'rgba(255, 106, 0, 0.4)'; +const GLOW_ACCENT = 'rgba(255, 204, 0, 0.35)'; + +// Layout +const PADDING_RATIO = 0.08; // canvas padding as fraction of min dimension +const OUTER_RADIUS_RATIO = 0.90; // outer ring at 90% of available radius +const INNER_RADIUS_RATIO = 0.30; // inner ring at 30% of available radius + +// Node sizing +const NODE_MIN_RADIUS = 4; +const NODE_MAX_RADIUS = 18; +const NODE_OUTLINE_WIDTH = 1.5; + +// Current-step indicator +const INDICATOR_EXTRA_RADIUS = 8; +const INDICATOR_LINE_WIDTH = 2; + +// Center dot +const CENTER_DOT_RADIUS = 3; + +// ── StepVisualizer ────────────────────────────────────────────────── + +export class StepVisualizer { + /** + * @param {{ canvas: HTMLCanvasElement, eventBus: import('./event-bus.js').EventBus }} opts + */ + constructor({ canvas, eventBus }) { + this._canvas = canvas; + this._ctx = canvas.getContext('2d'); + this._bus = eventBus; + + // State + this._pattern = null; // current pattern description + this._currentStep = -1; // playback position (-1 = none) + this._width = 0; + this._height = 0; + this._cx = 0; // center x + this._cy = 0; // center y + this._maxRadius = 0; // max ring radius in pixels + + // Pre-allocated arrays to avoid per-frame allocation. + // Sized lazily when pattern is set. + this._nodeX = null; // Float64Array — screen x per step + this._nodeY = null; // Float64Array — screen y per step + this._nodeR = null; // Float64Array — rendered radius per step + + // Interaction + this._tapCallback = null; + this._onPointerDown = this._handlePointerDown.bind(this); + + // Event bus subscription + this._onStep = this._handleStep.bind(this); + this._bus.on(SEQ.STEP, this._onStep); + + // Canvas interaction + this._canvas.addEventListener('pointerdown', this._onPointerDown); + + // Initial sizing + this.resize(canvas.getBoundingClientRect().width, canvas.getBoundingClientRect().height); + } + + // ── Public API ────────────────────────────────────────────────── + + /** + * Update the displayed pattern. + * @param {{ steps: Array, stepCount: number, metadata: Object }} patternDesc + */ + setPattern(patternDesc) { + this._pattern = patternDesc; + const count = patternDesc ? patternDesc.stepCount : 0; + + // (Re)allocate coordinate buffers only when step count changes + if (!this._nodeX || this._nodeX.length !== count) { + this._nodeX = new Float64Array(count); + this._nodeY = new Float64Array(count); + this._nodeR = new Float64Array(count); + } + + this._computeLayout(); + } + + /** + * Update the playback position. + * @param {number} index — step index (0-based), or -1 for none + */ + setCurrentStep(index) { + this._currentStep = index; + } + + /** + * Draw one frame. Call from requestAnimationFrame. + */ + render() { + const ctx = this._ctx; + const w = this._width; + const h = this._height; + + // Clear + ctx.fillStyle = COLOR_BG; + ctx.fillRect(0, 0, w, h); + + if (!this._pattern || this._pattern.stepCount === 0) return; + + const steps = this._pattern.steps; + const count = this._pattern.stepCount; + const cx = this._cx; + const cy = this._cy; + + // Draw connecting ring (subtle guide circle at midpoint radius) + const midRadius = this._maxRadius * ((OUTER_RADIUS_RATIO + INNER_RADIUS_RATIO) * 0.5); + ctx.beginPath(); + ctx.arc(cx, cy, midRadius, 0, TWO_PI); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; + ctx.lineWidth = 1; + ctx.stroke(); + + // Center dot + ctx.beginPath(); + ctx.arc(cx, cy, CENTER_DOT_RADIUS, 0, TWO_PI); + ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; + ctx.fill(); + + // Draw connector line from center to current step + if (this._currentStep >= 0 && this._currentStep < count) { + const si = this._currentStep; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(this._nodeX[si], this._nodeY[si]); + ctx.strokeStyle = 'rgba(255, 106, 0, 0.2)'; + ctx.lineWidth = INDICATOR_LINE_WIDTH; + ctx.stroke(); + } + + // Draw step nodes + for (let i = 0; i < count; i++) { + const step = steps[i]; + const nx = this._nodeX[i]; + const ny = this._nodeY[i]; + const nr = this._nodeR[i]; + const isCurrent = i === this._currentStep; + + if (isCurrent) { + // Outer glow for current step + ctx.beginPath(); + ctx.arc(nx, ny, nr + INDICATOR_EXTRA_RADIUS, 0, TWO_PI); + ctx.fillStyle = GLOW_CURRENT; + ctx.fill(); + } + + if (step.trigger) { + // Glow behind active nodes + if (!isCurrent) { + const glowColor = step.accent ? GLOW_ACCENT : GLOW_ACTIVE; + ctx.beginPath(); + ctx.arc(nx, ny, nr + 4, 0, TWO_PI); + ctx.fillStyle = glowColor; + ctx.fill(); + } + + // Filled node + ctx.beginPath(); + ctx.arc(nx, ny, nr, 0, TWO_PI); + if (isCurrent) { + ctx.fillStyle = COLOR_CURRENT; + } else if (step.accent) { + ctx.fillStyle = COLOR_ACCENT; + } else { + ctx.fillStyle = COLOR_ACTIVE; + } + ctx.fill(); + } else { + // Dim outline only for untriggered steps + ctx.beginPath(); + ctx.arc(nx, ny, nr, 0, TWO_PI); + ctx.strokeStyle = isCurrent ? COLOR_CURRENT : COLOR_INACTIVE; + ctx.lineWidth = NODE_OUTLINE_WIDTH; + ctx.stroke(); + } + } + } + + /** + * Handle canvas resize. + * @param {number} width — CSS pixels + * @param {number} height — CSS pixels + */ + resize(width, height) { + const dpr = window.devicePixelRatio || 1; + this._canvas.width = width * dpr; + this._canvas.height = height * dpr; + this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + this._width = width; + this._height = height; + this._cx = width * 0.5; + this._cy = height * 0.5; + + const minDim = Math.min(width, height); + this._maxRadius = (minDim * 0.5) * (1 - PADDING_RATIO * 2); + + this._computeLayout(); + } + + /** + * Register a tap callback. + * @param {function(number): void} callback — receives step index + */ + onStepTap(callback) { + this._tapCallback = callback; + } + + /** + * Unsubscribe from event bus and remove DOM listeners. + */ + destroy() { + this._bus.off(SEQ.STEP, this._onStep); + this._canvas.removeEventListener('pointerdown', this._onPointerDown); + this._tapCallback = null; + this._pattern = null; + } + + // ── Private ───────────────────────────────────────────────────── + + /** + * Re-compute node positions from current pattern + canvas size. + * Called when pattern or size changes — NOT per frame. + */ + _computeLayout() { + if (!this._pattern || !this._nodeX) return; + + const steps = this._pattern.steps; + const count = this._pattern.stepCount; + const cx = this._cx; + const cy = this._cy; + const maxR = this._maxRadius; + const outerR = maxR * OUTER_RADIUS_RATIO; + const innerR = maxR * INNER_RADIUS_RATIO; + const radiusRange = outerR - innerR; + + // Angular step: start at top (-PI/2), go clockwise + const angleStep = TWO_PI / count; + + for (let i = 0; i < count; i++) { + const step = steps[i]; + const angle = -HALF_PI + angleStep * i; + + // Pitch -> radial distance: low pitch = outer, high pitch = inner + const pitchNorm = step.pitch; // 0 = low (outer), 1 = high (inner) + const r = outerR - pitchNorm * radiusRange; + + this._nodeX[i] = cx + Math.cos(angle) * r; + this._nodeY[i] = cy + Math.sin(angle) * r; + + // Velocity -> node size + this._nodeR[i] = NODE_MIN_RADIUS + step.velocity * (NODE_MAX_RADIUS - NODE_MIN_RADIUS); + } + } + + /** + * Handle seq.step events from the event bus. + */ + _handleStep(data) { + if (typeof data.stepIndex === 'number') { + this._currentStep = data.stepIndex; + } + } + + /** + * Handle pointer down on the canvas for tap interaction. + */ + _handlePointerDown(e) { + if (!this._tapCallback || !this._pattern) return; + + const rect = this._canvas.getBoundingClientRect(); + const px = e.clientX - rect.left; + const py = e.clientY - rect.top; + const count = this._pattern.stepCount; + + // Find closest step within hit radius + let bestIdx = -1; + let bestDistSq = Infinity; + + for (let i = 0; i < count; i++) { + const dx = px - this._nodeX[i]; + const dy = py - this._nodeY[i]; + const distSq = dx * dx + dy * dy; + // Hit area is the node radius + some tolerance + const hitR = this._nodeR[i] + 12; + if (distSq < hitR * hitR && distSq < bestDistSq) { + bestDistSq = distSq; + bestIdx = i; + } + } + + if (bestIdx >= 0) { + this._tapCallback(bestIdx); + } + } +} diff --git a/playground/TODOS.md b/playground/TODOS.md index 4aa9f57..d545325 100644 --- a/playground/TODOS.md +++ b/playground/TODOS.md @@ -1,9 +1,9 @@ ## synth view Immersive UI -- [ ] add hover tooltip for each parameter slider -- [ ] hovering on the name of a module/group at the top should open a little drawer panel that allows us to set - - [ ] minimum and maximum values for each parameter (similar to what tame does) - - [ ] a curve parameter that's normalised and goes between logarithmic and exponential, with a little graph to visualise, to skew the distribution in either direction -- [ ] if the audio engine hasn't been initialised yet, the play button at the top left should be pulsing and have an orange highlight - +- [x] add hover tooltip for each parameter slider (canvas tooltip follows mouse, shows name/value/range/curve) +- [x] hovering on the name of a module/group at the top should open a little drawer panel that allows us to set + - [x] minimum and maximum values for each parameter (dual-thumb range slider) + - [x] a curve parameter that's normalised and goes between logarithmic and exponential, with a little graph to visualise, to skew the distribution in either direction (per-param draggable canvas + group master curve with relative adjustment) + - [x] mute toggle per parameter (removes from NISPS, replaces with fixed value slider) +- [x] if the audio engine hasn't been initialised yet, the play button at the top left should be pulsing and have an orange highlight diff --git a/playground/a-immersive.html b/playground/a-immersive.html index 165263d..941e9df 100644 --- a/playground/a-immersive.html +++ b/playground/a-immersive.html @@ -54,19 +54,26 @@ + + + - - - - +
@@ -95,60 +102,122 @@
- +
+
- -