From affec8f60450409dee681794f45c4c52465dd92a Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Sun, 22 Mar 2026 21:57:21 +0200 Subject: [PATCH] feat(playground): add synth parameter tooltips, group drawer with per-param curve/mute controls - Canvas tooltip follows mouse over synth bars showing param name, value, range, curve - Group drawer: per-param draggable curve canvas, dual-thumb min/max range slider, mute toggle - Muted params hide from visualizer (bars redistribute), use fixed value slider instead - Group curve drag applies relative delta preserving individual param offsets - Pulsing orange play button when audio engine not initialized (all UI modes) --- playground/TODOS.md | 9 + playground/css/a-immersive.css | 295 ++++++++++++++- playground/css/b-workbench.css | 15 + playground/css/c-journey.css | 16 + playground/js/a-app.js | 598 +++++++++++++++++++++++++++++-- playground/js/b-app.js | 5 + playground/js/c-app.js | 12 + playground/js/synth/param-map.js | 102 ++++-- 8 files changed, 994 insertions(+), 58 deletions(-) create mode 100644 playground/TODOS.md diff --git a/playground/TODOS.md b/playground/TODOS.md new file mode 100644 index 0000000..4aa9f57 --- /dev/null +++ b/playground/TODOS.md @@ -0,0 +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 + diff --git a/playground/css/a-immersive.css b/playground/css/a-immersive.css index ce062a9..e3f46d5 100644 --- a/playground/css/a-immersive.css +++ b/playground/css/a-immersive.css @@ -38,7 +38,7 @@ html, body { /* ---- Back button ---- */ .back-btn { position: fixed; - top: 30px; + top: 45px; left: 8px; z-index: 30; width: 36px; @@ -702,10 +702,32 @@ html, body { font-variant-numeric: tabular-nums; } +/* Tooltip for raw param sliders */ +.raw-param { + position: relative; +} + +.raw-param[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 4px 10px; + background: rgba(0, 0, 0, 0.9); + border: 1px solid var(--glass-border); + border-radius: 6px; + font-size: 12px; + color: var(--text); + white-space: nowrap; + pointer-events: none; + z-index: 50; +} + /* ---- Synth quick controls (play button next to back) ---- */ .synth-quick-controls { position: fixed; - top: 30px; + top: 45px; left: 52px; z-index: 30; display: flex; @@ -742,6 +764,23 @@ html, body { background: rgba(255, 106, 0, 0.12); } +.play-btn.audio-needs-init { + animation: audioInitPulse 2s ease-in-out infinite; +} + +@keyframes audioInitPulse { + 0%, 100% { + border-color: rgba(255, 106, 0, 0.3); + box-shadow: 0 0 6px rgba(255, 106, 0, 0.15); + color: var(--accent); + } + 50% { + border-color: rgba(255, 106, 0, 0.6); + box-shadow: 0 0 14px rgba(255, 106, 0, 0.3); + color: #ffaa55; + } +} + .play-drawer { display: none; flex-direction: column; @@ -788,6 +827,258 @@ html, body { display: none !important; } +/* ---- Group Override Drawer ---- */ +.group-drawer { + position: fixed; + z-index: 50; + width: 320px; + max-height: 60vh; + overflow-y: auto; + background: rgba(13, 13, 13, 0.92); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-sm); + padding: 0; + opacity: 0; + pointer-events: none; + transform: translateY(-4px); + transition: opacity 0.15s, transform 0.15s; +} + +.group-drawer.visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.group-drawer-header { + padding: 6px 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.5px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.group-drawer-body { + padding: 6px 8px 8px; +} + +/* Group curve row */ +.gd-curve-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.gd-label { + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + min-width: 34px; + flex-shrink: 0; +} + +.gd-curve-canvas { + width: 48px; + height: 48px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +.gd-val { + font-size: 10px; + color: var(--text-dim); + min-width: 30px; + text-align: right; + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +/* Per-param row */ +.gd-param-row { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 2px; + min-height: 28px; +} + +.gd-param-name { + font-size: 9px; + color: var(--text-dim); + min-width: 52px; + max-width: 52px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; +} + +/* Per-param curve mini-canvas */ +.gd-param-curve-canvas { + width: 28px; + height: 28px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +/* Dual-range slider wrapper */ +.gd-range-wrap { + position: relative; + flex: 1; + height: 16px; + min-width: 0; +} + +.gd-range-fill { + position: absolute; + top: 50%; + height: 3px; + transform: translateY(-50%); + background: var(--accent); + border-radius: 2px; + pointer-events: none; + opacity: 0.6; +} + +.gd-range-input { + position: absolute; + width: 100%; + top: 0; + left: 0; + height: 16px; + margin: 0; + padding: 0; + -webkit-appearance: none; + appearance: none; + background: transparent; + pointer-events: none; +} + +.gd-range-input::-webkit-slider-runnable-track { + height: 3px; + background: transparent; + border-radius: 2px; +} + +.gd-range-input::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + pointer-events: auto; + width: 10px; + height: 10px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + margin-top: -3.5px; +} + +.gd-range-min::-webkit-slider-thumb { + background: #4488ff; +} + +.gd-range-max::-webkit-slider-thumb { + background: #ff6a00; +} + +/* Firefox dual-range */ +.gd-range-input::-moz-range-track { + height: 3px; + background: transparent; + border: none; +} + +.gd-range-input::-moz-range-thumb { + pointer-events: auto; + width: 10px; + height: 10px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.3); + cursor: pointer; +} + +.gd-range-min::-moz-range-thumb { + background: #4488ff; +} + +.gd-range-max::-moz-range-thumb { + background: #ff6a00; +} + +/* Value slider (shown when muted) */ +.gd-val-slider { + flex: 1; + min-width: 0; + accent-color: #888; + height: 3px; + display: none; +} + +/* Mute button */ +.gd-mute-btn { + width: 20px; + height: 20px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: transparent; + color: rgba(255, 255, 255, 0.25); + font-size: 9px; + font-weight: 700; + cursor: pointer; + flex-shrink: 0; + padding: 0; + line-height: 18px; + text-align: center; + transition: all 0.1s; +} + +.gd-mute-btn:hover { + border-color: rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.5); +} + +.gd-mute-btn.muted { + background: rgba(255, 60, 60, 0.2); + border-color: rgba(255, 60, 60, 0.4); + color: #ff5555; +} + +/* Muted param row: hide curve + range, show value slider */ +.gd-param-row.gd-muted .gd-param-curve-canvas, +.gd-param-row.gd-muted .gd-range-wrap { + display: none; +} + +.gd-param-row.gd-muted .gd-val-slider { + display: block; +} + +.gd-param-row.gd-muted .gd-param-name { + color: rgba(255, 255, 255, 0.2); + text-decoration: line-through; +} + +/* Drawer scrollbar */ +.group-drawer::-webkit-scrollbar { + width: 3px; +} + +.group-drawer::-webkit-scrollbar-track { + background: transparent; +} + +.group-drawer::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; +} + /* ---- Scrollbar styling ---- */ .bottom-sheet::-webkit-scrollbar { width: 4px; diff --git a/playground/css/b-workbench.css b/playground/css/b-workbench.css index cd01ecf..db1b4ce 100644 --- a/playground/css/b-workbench.css +++ b/playground/css/b-workbench.css @@ -537,6 +537,21 @@ body.synth-mode { } .wb-btn-primary:hover { filter: brightness(1.15); } +.wb-btn-primary.audio-needs-init { + animation: audioInitPulse 2s ease-in-out infinite; +} + +@keyframes audioInitPulse { + 0%, 100% { + box-shadow: 0 0 6px rgba(255, 106, 0, 0.2); + filter: brightness(1); + } + 50% { + box-shadow: 0 0 14px rgba(255, 106, 0, 0.4); + filter: brightness(1.15); + } +} + .wb-btn-accent { border-color: var(--accent); color: var(--accent); diff --git a/playground/css/c-journey.css b/playground/css/c-journey.css index 5d3aad0..0917fea 100644 --- a/playground/css/c-journey.css +++ b/playground/css/c-journey.css @@ -685,6 +685,22 @@ html, body { background: rgba(255, 106, 0, 0.2); } +.action-btn.accent.audio-needs-init, +.audio-btn.audio-needs-init { + animation: audioInitPulse 2s ease-in-out infinite; +} + +@keyframes audioInitPulse { + 0%, 100% { + border-color: rgba(255, 106, 0, 0.3); + box-shadow: 0 0 6px rgba(255, 106, 0, 0.15); + } + 50% { + border-color: rgba(255, 106, 0, 0.6); + box-shadow: 0 0 14px rgba(255, 106, 0, 0.3); + } +} + .action-btn.dim { color: var(--text-dim); border-color: transparent; diff --git a/playground/js/a-app.js b/playground/js/a-app.js index 9ce57d9..92ac2d4 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -6,7 +6,7 @@ import { FlowFieldVisualizer } from './ui/visualizer.js'; import { C15Bridge } from './synth/c15-bridge.js'; import { Arpeggiator } from './synth/arpeggiator.js'; import { MIDIInput } from './synth/midi-input.js'; -import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyTame } from './synth/param-map.js'; +import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyTame, applyCurve, applyGroupOverride } from './synth/param-map.js'; import { GamepadInput } from './ui/gamepad.js'; // ---- Constants ---- @@ -122,6 +122,53 @@ const SYNTH_SECTIONS = [ { name: 'Mono', count: 1, color: '#999999' }, ]; +// ---- Group Overrides: per-group curve + per-param min/max/curve/mute ---- +// groupOverrides[sectionIndex] = { curve: 0.5, params: [{ min, max, curve, muted, fixedValue }, ...] } +const groupOverrides = SYNTH_SECTIONS.map(sec => ({ + curve: 0.5, + params: new Array(sec.count).fill(null).map(() => ({ + min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5, + })), +})); + +// Build a flat lookup: paramIndex -> { sectionIndex, localIndex } +const paramToSection = []; +{ + let idx = 0; + for (let si = 0; si < SYNTH_SECTIONS.length; si++) { + for (let li = 0; li < SYNTH_SECTIONS[si].count; li++) { + paramToSection.push({ si, li }); + idx++; + } + } + // Pad for any params beyond sections + while (paramToSection.length < N_SYNTH_OUTPUTS) { + paramToSection.push(null); + } +} + +/** + * Apply group overrides (per-param curve + min/max) to a single ML output value. + * Returns the remapped value, or fixedValue if the param is muted. + */ +function applyGroupOverrides(rawValue, paramIndex) { + const mapping = paramToSection[paramIndex]; + if (!mapping) return rawValue; + const ov = groupOverrides[mapping.si]; + const p = ov.params[mapping.li]; + if (p.muted) return p.fixedValue; + // Per-param curve overrides group curve (if param curve != 0.5, use it; else use group curve) + const curve = p.curve !== 0.5 ? p.curve : ov.curve; + return applyGroupOverride(rawValue, curve, p.min, p.max); +} + +/** Check if param at given index is muted */ +function isParamMuted(paramIndex) { + const mapping = paramToSection[paramIndex]; + if (!mapping) return false; + return groupOverrides[mapping.si].params[mapping.li].muted; +} + // ---- SynthVisualizer class ---- class SynthVisualizer { constructor(canvas) { @@ -138,6 +185,10 @@ class SynthVisualizer { this._dragBarIndex = -1; this._interactionEnabled = false; + // Hover tooltip state + this._hoveredBar = -1; + this._tooltipEl = null; + // Build section map this.sectionMap = []; let idx = 0; @@ -151,6 +202,21 @@ class SynthVisualizer { this.sectionMap.push({ name: 'Other', count: 1, color: '#666666' }); } + // Always-on hover tracking for tooltip (independent of enableInteraction) + this.canvas.addEventListener('pointermove', (e) => { + if (this._dragging) return; // interaction handler takes over + const idx = this.hitTest(e.clientX, e.clientY); + this._hoveredBar = idx; + // Store mouse position in canvas pixels for tooltip + const rect = this.canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + this._mouseCanvasX = (e.clientX - rect.left) * dpr; + this._mouseCanvasY = (e.clientY - rect.top) * dpr; + }); + this.canvas.addEventListener('pointerleave', () => { + this._hoveredBar = -1; + }); + this.resize(); } @@ -183,50 +249,81 @@ class SynthVisualizer { ctx.fillStyle = '#0a0a0a'; ctx.fillRect(0, 0, W, H); - // Calculate section gap positions + // Build list of visible (non-muted) param indices + const visibleIndices = []; + for (let i = 0; i < n; i++) { + if (!isParamMuted(i)) visibleIndices.push(i); + } + const nVisible = visibleIndices.length || 1; + + // Calculate section gap positions (among visible params only) const sectionGaps = new Set(); - let ci = 0; - for (const sec of SYNTH_SECTIONS) { - ci += sec.count; - if (ci < n) sectionGaps.add(ci); + let prevSi = -1; + for (const vi of visibleIndices) { + const mapping = paramToSection[vi]; + const curSi = mapping ? mapping.si : -1; + if (prevSi >= 0 && curSi !== prevSi) { + sectionGaps.add(vi); + } + prevSi = curSi; } const topPad = this.topPadding * dpr; const bottomPad = this.bottomPadding * dpr; const totalGapPx = sectionGaps.size * 2 * dpr; const barAreaWidth = W - totalGapPx; - const barWidth = barAreaWidth / n; + const barWidth = barAreaWidth / nVisible; const usableHeight = H - topPad - bottomPad; const maxBarHeight = usableHeight; // Store layout for interaction hit-testing - this._layout = { W, H, n, barWidth, totalGapPx, sectionGaps, topPad, bottomPad, usableHeight, dpr }; + this._layout = { W, H, n, barWidth, totalGapPx, sectionGaps, topPad, bottomPad, usableHeight, dpr, visibleIndices }; // Draw bars let x = 0; - let prevSection = this.sectionMap[0]; + let prevSection = null; let sectionStartX = 0; - // Store bar x positions for hit testing - this._barXPositions = []; + let sectionIndex = -1; + // Store bar x positions for hit testing (indexed by original param index) + this._barXPositions = new Array(n).fill(-1); + this._barWidths = new Array(n).fill(0); + // Store section label regions (in CSS pixels) for drawer hit testing + this._sectionLabelRegions = []; - for (let i = 0; i < n; i++) { + for (let vi = 0; vi < visibleIndices.length; vi++) { + const i = visibleIndices[vi]; const sec = this.sectionMap[i]; + const mapping = paramToSection[i]; + const curSi = mapping ? mapping.si : -1; // Section divider gap if (sectionGaps.has(i)) { // Draw section label for the previous section at top - this._drawSectionLabel(ctx, prevSection.name, sectionStartX, x, topPad, prevSection.color); + if (prevSection) { + this._drawSectionLabel(ctx, prevSection.name, sectionStartX, x, topPad, prevSection.color); + this._sectionLabelRegions.push({ + index: sectionIndex, + left: sectionStartX / dpr, + right: x / dpr, + top: 0, + bottom: topPad / dpr, + name: prevSection.name, + }); + } + sectionIndex = curSi; x += 2 * dpr; sectionStartX = x; prevSection = sec; } - if (i === 0) { + if (vi === 0) { sectionStartX = x; prevSection = sec; + sectionIndex = curSi; } this._barXPositions[i] = x; + this._barWidths[i] = barWidth; const val = this.displayParams[i]; const barH = val * maxBarHeight; @@ -246,7 +343,18 @@ class SynthVisualizer { // Draw final section label at top if (prevSection) { this._drawSectionLabel(ctx, prevSection.name, sectionStartX, x, topPad, prevSection.color); + this._sectionLabelRegions.push({ + index: sectionIndex, + left: sectionStartX / dpr, + right: x / dpr, + top: 0, + bottom: topPad / dpr, + name: prevSection.name, + }); } + + // Draw tooltip for hovered bar + this._drawTooltip(ctx, dpr); } _drawSectionLabel(ctx, name, startX, endX, topPad, color) { @@ -262,6 +370,20 @@ class SynthVisualizer { ctx.restore(); } + // Returns section region at client coordinates, or null + hitTestSection(clientX, clientY) { + if (!this._sectionLabelRegions) return null; + const rect = this.canvas.getBoundingClientRect(); + const px = clientX - rect.left; + const py = clientY - rect.top; + for (const region of this._sectionLabelRegions) { + if (px >= region.left && px <= region.right && py >= region.top && py <= region.bottom) { + return region; + } + } + return null; + } + // Returns bar index at canvas-relative pixel x, or -1 hitTest(clientX, clientY) { if (!this._barXPositions || !this._layout) return -1; @@ -269,11 +391,12 @@ class SynthVisualizer { const dpr = this._layout.dpr; const px = (clientX - rect.left) * dpr; const n = this._layout.n; - const barWidth = this._layout.barWidth; for (let i = 0; i < n; i++) { const bx = this._barXPositions[i]; - if (px >= bx && px < bx + barWidth) return i; + if (bx < 0) continue; // muted + const bw = this._barWidths[i] || this._layout.barWidth; + if (px >= bx && px < bx + bw) return i; } return -1; } @@ -336,6 +459,7 @@ class SynthVisualizer { this.canvas.style.cursor = ''; this._dragging = false; this._dragBarIndex = -1; + this._hoveredBar = -1; if (this._onPointerDown) { this.canvas.removeEventListener('pointerdown', this._onPointerDown); this.canvas.removeEventListener('pointermove', this._onPointerMove); @@ -344,6 +468,75 @@ class SynthVisualizer { } } } + + _drawTooltip(ctx, dpr) { + const i = this._hoveredBar; + if (i < 0 || !this._layout) return; + if (this._barXPositions[i] < 0) return; // muted + + const name = SYNTH_PARAM_NAMES[i] || `p${i}`; + const val = this.displayParams[i]; + const mapping = paramToSection[i]; + let rangeStr = '0.00 – 1.00'; + let curveStr = '0.50'; + if (mapping) { + const ov = groupOverrides[mapping.si]; + const p = ov.params[mapping.li]; + rangeStr = `${p.min.toFixed(2)} – ${p.max.toFixed(2)}`; + const curve = p.curve !== 0.5 ? p.curve : ov.curve; + curveStr = curve.toFixed(2); + } + + const lines = [name, `Val: ${val.toFixed(2)}`, `Range: ${rangeStr}`, `Curve: ${curveStr}`]; + const fontSize = 10 * dpr; + const lineHeight = fontSize * 1.4; + const padX = 8 * dpr; + const padY = 6 * dpr; + + ctx.save(); + ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; + + // Measure text + let maxW = 0; + for (const line of lines) { + const m = ctx.measureText(line); + if (m.width > maxW) maxW = m.width; + } + const boxW = maxW + padX * 2; + const boxH = lines.length * lineHeight + padY * 2; + + // Position near the mouse cursor + const mx = this._mouseCanvasX || 0; + const my = this._mouseCanvasY || 0; + const offset = 12 * dpr; + let tx = mx + offset; + let ty = my - boxH - offset; + // Clamp to canvas + if (tx + boxW > this._layout.W - 2 * dpr) tx = mx - boxW - offset; + if (ty < 2 * dpr) ty = my + offset; + if (tx < 2 * dpr) tx = 2 * dpr; + + // Background + ctx.fillStyle = 'rgba(0, 0, 0, 0.88)'; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.lineWidth = 1; + const r = 4 * dpr; + ctx.beginPath(); + ctx.roundRect(tx, ty, boxW, boxH, r); + ctx.fill(); + ctx.stroke(); + + // Text + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + for (let li = 0; li < lines.length; li++) { + ctx.fillStyle = li === 0 ? '#ffffff' : 'rgba(255,255,255,0.6)'; + if (li === 0) ctx.font = `bold ${fontSize}px 'JetBrains Mono', monospace`; + else ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; + ctx.fillText(lines[li], tx + padX, ty + padY + li * lineHeight); + } + ctx.restore(); + } } // ---- Preset padding ---- @@ -426,6 +619,7 @@ function init() { wireGamepad(); wireKeyboard(); wireQuickPlayControls(); + wireGroupDrawer(); // Resize window.addEventListener('resize', onResize); @@ -703,10 +897,15 @@ function onJoystickMove() { function routeOutputs(outputs) { if (outputMode === 'synth') { // Synth mode: synth visualizer + C15 - synthVisualizer.setParams(outputs); + // Apply group overrides before visualization and C15 + const overridden = new Array(outputs.length); + for (let i = 0; i < outputs.length; i++) { + overridden[i] = applyGroupOverrides(outputs[i], i); + } + synthVisualizer.setParams(overridden); if (c15 && c15.running) { - for (let i = 0; i < outputs.length && i < SYNTH_PARAM_MAP.length; i++) { - const tamed = applyTame(outputs[i], SYNTH_PARAM_MAP[i], tameLevel); + for (let i = 0; i < overridden.length && i < SYNTH_PARAM_MAP.length; i++) { + const tamed = applyTame(overridden[i], SYNTH_PARAM_MAP[i], tameLevel); c15.setParameter(SYNTH_PARAM_MAP[i].id, tamed); } } @@ -775,6 +974,7 @@ function syncOutputToggles(mode) { function setOutputMode(mode) { outputMode = mode; + hideGroupDrawer(); buildHeatmap(); updateHeatmap(iml.getOutputs()); @@ -788,6 +988,9 @@ function setOutputMode(mode) { heatmapStrip.classList.add('hidden'); synthQuickControls.classList.remove('hidden'); synthVisualizer.enableInteraction(true); + // Pulse play button if audio not yet started + const qp = document.getElementById('quick-play'); + if (qp) qp.classList.toggle('audio-needs-init', !(c15 && c15.running)); } else { $synthPanel.classList.add('hidden'); $canvas.classList.remove('hidden-canvas'); @@ -990,6 +1193,7 @@ function buildRawParams() { for (let i = 0; i < count; i++) { const row = document.createElement('div'); row.className = 'raw-param'; + row.dataset.tooltip = `${names[i]}: ${rawParamValues[i].toFixed(2)}`; const label = document.createElement('span'); label.className = 'raw-param-label'; @@ -1011,6 +1215,7 @@ function buildRawParams() { const idx = parseInt(slider.dataset.index); rawParamValues[idx] = parseFloat(slider.value); val.textContent = rawParamValues[idx].toFixed(2); + row.dataset.tooltip = `${names[idx]}: ${rawParamValues[idx].toFixed(2)}`; routeOutputs(rawParamValues); updateHeatmap(rawParamValues); }); @@ -1024,11 +1229,16 @@ function buildRawParams() { function syncRawParamsFromOutputs(outputs) { rawParamValues = [...outputs]; - const sliders = $rawParams.querySelectorAll('input[type="range"]'); - sliders.forEach((s, i) => { + const rows = $rawParams.querySelectorAll('.raw-param'); + rows.forEach((row, i) => { if (i < outputs.length) { - s.value = outputs[i]; - s.nextElementSibling.textContent = outputs[i].toFixed(2); + const s = row.querySelector('input[type="range"]'); + if (s) { + s.value = outputs[i]; + s.nextElementSibling.textContent = outputs[i].toFixed(2); + } + const name = row.querySelector('.raw-param-label')?.textContent || `p${i}`; + row.dataset.tooltip = `${name}: ${outputs[i].toFixed(2)}`; } }); } @@ -1044,14 +1254,17 @@ function wireSynthControls() { const arpOffset = document.getElementById('arp-offset'); startBtn.addEventListener('click', async () => { + const quickPlay = document.getElementById('quick-play'); if (c15.running) { arpeggiator.stop(); arpToggle.textContent = 'Play'; await c15.stop(); startBtn.textContent = 'Start Audio'; + if (quickPlay) quickPlay.classList.add('audio-needs-init'); } else { await c15.start(); startBtn.textContent = 'Stop Audio'; + if (quickPlay) quickPlay.classList.remove('audio-needs-init'); routeOutputs(iml.getOutputs()); } }); @@ -1205,6 +1418,7 @@ function wireQuickPlayControls() { const isPlaying = c15 && c15.running; quickPlayIcon.innerHTML = isPlaying ? pauseIconSVG : playIconSVG; quickPlay.classList.toggle('playing', isPlaying); + quickPlay.classList.toggle('audio-needs-init', !isPlaying); } quickPlay.addEventListener('click', async () => { @@ -1248,8 +1462,327 @@ function wireQuickPlayControls() { }); } +// ---- Group Override Drawer ---- +let $groupDrawer = null; +let activeDrawerSection = -1; +let drawerHideTimer = null; + +function wireGroupDrawer() { + // Create the drawer DOM element once + $groupDrawer = document.createElement('div'); + $groupDrawer.className = 'group-drawer'; + $groupDrawer.innerHTML = '
'; + document.body.appendChild($groupDrawer); + + // Keep drawer open while hovering over it + $groupDrawer.addEventListener('pointerenter', () => { + clearTimeout(drawerHideTimer); + }); + $groupDrawer.addEventListener('pointerleave', () => { + drawerHideTimer = setTimeout(() => hideGroupDrawer(), 300); + }); + + // Detect hover over section labels on the synth vis canvas + $synthVisCanvas.addEventListener('pointermove', (e) => { + if (outputMode !== 'synth') return; + const region = synthVisualizer.hitTestSection(e.clientX, e.clientY); + if (region) { + clearTimeout(drawerHideTimer); + if (activeDrawerSection !== region.index) { + showGroupDrawer(region); + } + } else { + // Leaving section label area, delay hide + if (activeDrawerSection >= 0) { + drawerHideTimer = setTimeout(() => hideGroupDrawer(), 300); + } + } + }); + + // Also handle click for mobile + $synthVisCanvas.addEventListener('pointerdown', (e) => { + if (outputMode !== 'synth') return; + const region = synthVisualizer.hitTestSection(e.clientX, e.clientY); + if (region) { + e.preventDefault(); + e.stopPropagation(); + clearTimeout(drawerHideTimer); + if (activeDrawerSection === region.index) { + hideGroupDrawer(); + } else { + showGroupDrawer(region); + } + } + }, true); // capture phase so it fires before bar interaction +} + +function showGroupDrawer(region) { + activeDrawerSection = region.index; + const sec = SYNTH_SECTIONS[region.index]; + const ov = groupOverrides[region.index]; + + // Find global param start index for this section + let paramStart = 0; + for (let i = 0; i < region.index; i++) paramStart += SYNTH_SECTIONS[i].count; + + // Header with section name + const header = $groupDrawer.querySelector('.group-drawer-header'); + header.textContent = sec.name; + header.style.color = sec.color; + + // Body: group curve + per-param rows + const body = $groupDrawer.querySelector('.group-drawer-body'); + body.innerHTML = ''; + + // -- Group master curve (draggable canvas) -- + const curveRow = document.createElement('div'); + curveRow.className = 'gd-curve-row'; + + const curveLabel = document.createElement('span'); + curveLabel.className = 'gd-label'; + curveLabel.textContent = 'Group'; + + const curveCanvas = document.createElement('canvas'); + curveCanvas.className = 'gd-curve-canvas'; + curveCanvas.width = 48; + curveCanvas.height = 48; + + const curveVal = document.createElement('span'); + curveVal.className = 'gd-val'; + curveVal.textContent = ov.curve.toFixed(2); + + function drawGroupCurvePreview() { + _drawCurveOnCanvas(curveCanvas, ov.curve, sec.color); + } + + // Vertical drag on group curve — applies relative delta to all param curves + { + let dragging = false, startY = 0, startGroupCurve = 0, startParamCurves = []; + curveCanvas.style.cursor = 'ns-resize'; + curveCanvas.style.touchAction = 'none'; + curveCanvas.addEventListener('pointerdown', (e) => { + e.preventDefault(); e.stopPropagation(); + dragging = true; + startY = e.clientY; + startGroupCurve = ov.curve; + startParamCurves = ov.params.map(p => p.curve); + curveCanvas.setPointerCapture(e.pointerId); + }); + curveCanvas.addEventListener('pointermove', (e) => { + if (!dragging) return; + e.preventDefault(); + const dy = e.clientY - startY; + const delta = dy / 80; + const newGroup = Math.max(0, Math.min(1, startGroupCurve + delta)); + ov.curve = newGroup; + curveVal.textContent = newGroup.toFixed(2); + // Apply same delta to each param, preserving relative offsets + for (let i = 0; i < ov.params.length; i++) { + ov.params[i].curve = Math.max(0, Math.min(1, startParamCurves[i] + delta)); + } + drawGroupCurvePreview(); + body.querySelectorAll('.gd-param-curve-canvas').forEach(c => { + if (c._redraw) c._redraw(); + }); + routeOutputs(iml.getOutputs()); + }); + curveCanvas.addEventListener('pointerup', () => { dragging = false; }); + curveCanvas.addEventListener('pointercancel', () => { dragging = false; }); + } + + curveRow.appendChild(curveLabel); + curveRow.appendChild(curveCanvas); + curveRow.appendChild(curveVal); + body.appendChild(curveRow); + drawGroupCurvePreview(); + + // -- Per-param rows -- + for (let li = 0; li < sec.count; li++) { + const pi = paramStart + li; + if (pi >= SYNTH_PARAM_MAP.length) break; + const param = SYNTH_PARAM_MAP[pi]; + const pov = ov.params[li]; + + const row = document.createElement('div'); + row.className = 'gd-param-row'; + if (pov.muted) row.classList.add('gd-muted'); + + // Name + const nameSpan = document.createElement('span'); + nameSpan.className = 'gd-param-name'; + nameSpan.textContent = param.label; + + // Per-param curve canvas (vertically draggable, no slider) + const pCurveCanvas = document.createElement('canvas'); + pCurveCanvas.className = 'gd-param-curve-canvas'; + pCurveCanvas.width = 28; + pCurveCanvas.height = 28; + pCurveCanvas._redraw = () => _drawCurveOnCanvas(pCurveCanvas, pov.curve, sec.color); + + _wireCurveDrag(pCurveCanvas, () => pov.curve, (v) => { + pov.curve = v; + pCurveCanvas._redraw(); + routeOutputs(iml.getOutputs()); + }); + pCurveCanvas._redraw(); + + // Dual-range slider (min/max as two overlapping range inputs) + const rangeWrap = document.createElement('div'); + rangeWrap.className = 'gd-range-wrap'; + + const rangeFill = document.createElement('div'); + rangeFill.className = 'gd-range-fill'; + + const minSlider = document.createElement('input'); + minSlider.type = 'range'; minSlider.min = '0'; minSlider.max = '1'; minSlider.step = '0.01'; + minSlider.value = pov.min; + minSlider.className = 'gd-range-input gd-range-min'; + + const maxSlider = document.createElement('input'); + maxSlider.type = 'range'; maxSlider.min = '0'; maxSlider.max = '1'; maxSlider.step = '0.01'; + maxSlider.value = pov.max; + maxSlider.className = 'gd-range-input gd-range-max'; + + // Value slider (shown when muted) + const valSlider = document.createElement('input'); + valSlider.type = 'range'; valSlider.min = '0'; valSlider.max = '1'; valSlider.step = '0.01'; + valSlider.value = pov.fixedValue; + valSlider.className = 'gd-val-slider'; + + function updateRangeFill() { + rangeFill.style.left = `${pov.min * 100}%`; + rangeFill.style.width = `${(pov.max - pov.min) * 100}%`; + } + updateRangeFill(); + + minSlider.addEventListener('input', () => { + pov.min = parseFloat(minSlider.value); + if (pov.min > pov.max) { pov.max = pov.min; maxSlider.value = pov.max; } + updateRangeFill(); + routeOutputs(iml.getOutputs()); + }); + maxSlider.addEventListener('input', () => { + pov.max = parseFloat(maxSlider.value); + if (pov.max < pov.min) { pov.min = pov.max; minSlider.value = pov.min; } + updateRangeFill(); + routeOutputs(iml.getOutputs()); + }); + valSlider.addEventListener('input', () => { + pov.fixedValue = parseFloat(valSlider.value); + routeOutputs(iml.getOutputs()); + }); + + rangeWrap.appendChild(rangeFill); + rangeWrap.appendChild(minSlider); + rangeWrap.appendChild(maxSlider); + + // Mute toggle + const muteBtn = document.createElement('button'); + muteBtn.className = 'gd-mute-btn' + (pov.muted ? ' muted' : ''); + muteBtn.textContent = pov.muted ? 'M' : 'M'; + muteBtn.title = pov.muted ? 'Unmute (re-enable NISPS control)' : 'Mute (remove from NISPS)'; + + muteBtn.addEventListener('click', () => { + pov.muted = !pov.muted; + muteBtn.classList.toggle('muted', pov.muted); + muteBtn.title = pov.muted ? 'Unmute (re-enable NISPS control)' : 'Mute (remove from NISPS)'; + row.classList.toggle('gd-muted', pov.muted); + routeOutputs(iml.getOutputs()); + }); + + row.appendChild(nameSpan); + row.appendChild(pCurveCanvas); + row.appendChild(rangeWrap); + row.appendChild(valSlider); + row.appendChild(muteBtn); + body.appendChild(row); + } + + // Position the drawer below the section label + const canvasRect = $synthVisCanvas.getBoundingClientRect(); + const centerX = (region.left + region.right) / 2 + canvasRect.left; + const topY = region.bottom + canvasRect.top + 4; + + const drawerWidth = 320; + let left = centerX - drawerWidth / 2; + left = Math.max(4, Math.min(left, window.innerWidth - drawerWidth - 4)); + + $groupDrawer.style.left = `${left}px`; + $groupDrawer.style.top = `${topY}px`; + $groupDrawer.classList.add('visible'); +} + +/** Draw a curve preview on a canvas element */ +function _drawCurveOnCanvas(canvas, curveFactor, color) { + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, w, h); + + ctx.fillStyle = 'rgba(255,255,255,0.03)'; + ctx.fillRect(0, 0, w, h); + + // Linear reference + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, h); + ctx.lineTo(w, 0); + ctx.stroke(); + + // Curve + ctx.strokeStyle = color; + ctx.lineWidth = Math.min(2, w / 16); + ctx.beginPath(); + const steps = 30; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const v = applyCurve(t, curveFactor); + const px = t * w; + const py = (1 - v) * h; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.stroke(); +} + +/** Wire vertical drag on a canvas to control a curve factor */ +function _wireCurveDrag(canvas, getter, setter) { + let dragging = false; + let startY = 0; + let startVal = 0; + + canvas.style.cursor = 'ns-resize'; + canvas.style.touchAction = 'none'; + + canvas.addEventListener('pointerdown', (e) => { + e.preventDefault(); + e.stopPropagation(); + dragging = true; + startY = e.clientY; + startVal = getter(); + canvas.setPointerCapture(e.pointerId); + }); + canvas.addEventListener('pointermove', (e) => { + if (!dragging) return; + e.preventDefault(); + // Drag down = more exponential (higher curve), drag up = more logarithmic (lower curve) + const dy = e.clientY - startY; + const newVal = Math.max(0, Math.min(1, startVal + dy / 80)); + setter(newVal); + }); + canvas.addEventListener('pointerup', () => { dragging = false; }); + canvas.addEventListener('pointercancel', () => { dragging = false; }); +} + +function hideGroupDrawer() { + activeDrawerSection = -1; + if ($groupDrawer) $groupDrawer.classList.remove('visible'); +} + // ---- Resize ---- function onResize() { + hideGroupDrawer(); visualizer.resize(); visualizer.initParticles(); synthVisualizer.resize(); @@ -1292,6 +1825,7 @@ function saveState() { outputMode, joyX, joyY, + groupOverrides, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { @@ -1319,6 +1853,24 @@ function loadState() { if (typeof state.joyX === 'number') joyX = state.joyX; if (typeof state.joyY === 'number') joyY = state.joyY; + // Restore group overrides + if (state.groupOverrides && Array.isArray(state.groupOverrides)) { + for (let si = 0; si < state.groupOverrides.length && si < groupOverrides.length; si++) { + const saved = state.groupOverrides[si]; + if (typeof saved.curve === 'number') groupOverrides[si].curve = saved.curve; + if (Array.isArray(saved.params)) { + for (let li = 0; li < saved.params.length && li < groupOverrides[si].params.length; li++) { + const sp = saved.params[li], gp = groupOverrides[si].params[li]; + if (typeof sp.min === 'number') gp.min = sp.min; + if (typeof sp.max === 'number') gp.max = sp.max; + if (typeof sp.curve === 'number') gp.curve = sp.curve; + if (typeof sp.muted === 'boolean') gp.muted = sp.muted; + if (typeof sp.fixedValue === 'number') gp.fixedValue = sp.fixedValue; + } + } + } + } + // Restore output mode if (state.outputMode && state.outputMode !== outputMode) { setOutputMode(state.outputMode); diff --git a/playground/js/b-app.js b/playground/js/b-app.js index b2ee506..bc5988b 100644 --- a/playground/js/b-app.js +++ b/playground/js/b-app.js @@ -898,6 +898,9 @@ function setOutputMode(mode) { // Show synth canvas, hide visual canvas visualCanvas.classList.add('hidden'); synthVisCanvas.classList.remove('hidden'); + // Pulse Start Audio button if audio not yet started + const startBtn = document.getElementById('synth-start'); + if (startBtn) startBtn.classList.toggle('audio-needs-init', !(c15 && c15.running)); } else { document.body.classList.remove('synth-mode'); badge.textContent = 'Visual'; @@ -987,9 +990,11 @@ function initSynthControls() { arpToggleBtn.textContent = 'Arp: Play'; await c15.stop(); startBtn.textContent = 'Start Audio'; + startBtn.classList.add('audio-needs-init'); } else { await c15.start(); startBtn.textContent = 'Stop Audio'; + startBtn.classList.remove('audio-needs-init'); routeOutputs(iml.getOutputs()); } }); diff --git a/playground/js/c-app.js b/playground/js/c-app.js index 17b47c5..7a3bf01 100644 --- a/playground/js/c-app.js +++ b/playground/js/c-app.js @@ -501,6 +501,10 @@ function setOutputMode(mode) { currentParamNames = SYNTH_PARAM_NAMES; currentParamColors = SYNTH_PARAM_COLORS; document.getElementById('synth-section')?.classList.remove('hidden'); + // Pulse audio buttons if audio not yet started + const needsInit = !(c15 && c15.running); + document.getElementById('synth-start')?.classList.toggle('audio-needs-init', needsInit); + document.getElementById('audio-toggle')?.classList.toggle('audio-needs-init', needsInit); // Toggle canvases visC?.classList.add('hidden'); synthC?.classList.remove('hidden'); @@ -741,9 +745,13 @@ function wireSynthControls() { document.getElementById('arp-toggle').textContent = 'Play'; await c15.stop(); startBtn.textContent = 'Start Audio'; + startBtn.classList.add('audio-needs-init'); + document.getElementById('audio-toggle')?.classList.add('audio-needs-init'); } else { await c15.start(); startBtn.textContent = 'Stop Audio'; + startBtn.classList.remove('audio-needs-init'); + document.getElementById('audio-toggle')?.classList.remove('audio-needs-init'); routeOutputs(iml.getOutputs()); } }); @@ -781,10 +789,14 @@ function wireSynthControls() { arpeggiator.stop(); await c15.stop(); btn.textContent = '\u25B6'; + btn.classList.add('audio-needs-init'); + document.getElementById('synth-start')?.classList.add('audio-needs-init'); } else { await c15.start(); arpeggiator.start(); btn.textContent = '\u23F8'; + btn.classList.remove('audio-needs-init'); + document.getElementById('synth-start')?.classList.remove('audio-needs-init'); routeOutputs(iml.getOutputs()); } }); diff --git a/playground/js/synth/param-map.js b/playground/js/synth/param-map.js index 4f6f3c6..b567695 100644 --- a/playground/js/synth/param-map.js +++ b/playground/js/synth/param-map.js @@ -14,6 +14,10 @@ // - Silence (output levels kept above minimum) // - Voice buildup / wall of sound (envelope release/decay times capped, // reverb size and echo feedback limited) +// - Feedback runaway (FB mixer sources, comb decay, flanger FB capped) +// - Harsh noise (PM self-mod and FB amounts constrained) +// - Filter self-oscillation (SVF/comb/gap resonance capped) +// - Effect energy buildup (echo/reverb mix and cross-FB limited) export const SYNTH_PARAM_MAP = [ // --- Envelope A (7) --- @@ -43,17 +47,17 @@ export const SYNTH_PARAM_MAP = [ { id: 297, name: 'Env_C_Sus', label: 'EnvC Sus', defaultValue: 0, bipolar: true }, // --- Oscillator A (5) --- - { id: 57, name: 'Osc_A_Fluct', label: 'OscA Fluct', defaultValue: 0, bipolar: false }, - { id: 60, name: 'Osc_A_PM_Self', label: 'OscA PM-Self', defaultValue: 0, bipolar: true }, - { id: 64, name: 'Osc_A_PM_B', label: 'OscA PM-B', defaultValue: 0, bipolar: true }, - { id: 68, name: 'Osc_A_PM_FB', label: 'OscA PM-FB', defaultValue: 0, bipolar: true }, + { id: 57, name: 'Osc_A_Fluct', label: 'OscA Fluct', defaultValue: 0, bipolar: false, safeMax: 0.7 }, + { id: 60, name: 'Osc_A_PM_Self', label: 'OscA PM-Self', defaultValue: 0, bipolar: true, safeMin: 0.2, safeMax: 0.8 }, + { id: 64, name: 'Osc_A_PM_B', label: 'OscA PM-B', defaultValue: 0, bipolar: true, safeMin: 0.15, safeMax: 0.85 }, + { id: 68, name: 'Osc_A_PM_FB', label: 'OscA PM-FB', defaultValue: 0, bipolar: true, safeMin: 0.25, safeMax: 0.75 }, { id: 301, name: 'Osc_A_Phase', label: 'OscA Phase', defaultValue: 0, bipolar: true }, // --- Oscillator B (5) --- - { id: 87, name: 'Osc_B_Fluct', label: 'OscB Fluct', defaultValue: 0, bipolar: false }, - { id: 90, name: 'Osc_B_PM_Self', label: 'OscB PM-Self', defaultValue: 0, bipolar: true }, - { id: 94, name: 'Osc_B_PM_A', label: 'OscB PM-A', defaultValue: 0, bipolar: true }, - { id: 98, name: 'Osc_B_PM_FB', label: 'OscB PM-FB', defaultValue: 0, bipolar: true }, + { id: 87, name: 'Osc_B_Fluct', label: 'OscB Fluct', defaultValue: 0, bipolar: false, safeMax: 0.7 }, + { id: 90, name: 'Osc_B_PM_Self', label: 'OscB PM-Self', defaultValue: 0, bipolar: true, safeMin: 0.2, safeMax: 0.8 }, + { id: 94, name: 'Osc_B_PM_A', label: 'OscB PM-A', defaultValue: 0, bipolar: true, safeMin: 0.15, safeMax: 0.85 }, + { id: 98, name: 'Osc_B_PM_FB', label: 'OscB PM-FB', defaultValue: 0, bipolar: true, safeMin: 0.25, safeMax: 0.75 }, { id: 302, name: 'Osc_B_Phase', label: 'OscB Phase', defaultValue: 0, bipolar: true }, // --- Shaper A (6) --- @@ -61,7 +65,7 @@ export const SYNTH_PARAM_MAP = [ { id: 74, name: 'Shp_A_Fold', label: 'ShpA Fold', defaultValue: 0.5, bipolar: false }, { id: 75, name: 'Shp_A_Asym', label: 'ShpA Asym', defaultValue: 0, bipolar: false }, { id: 76, name: 'Shp_A_Mix', label: 'ShpA Mix', defaultValue: 0, bipolar: true }, - { id: 78, name: 'Shp_A_FB_Mix', label: 'ShpA FB Mix', defaultValue: 0, bipolar: false }, + { id: 78, name: 'Shp_A_FB_Mix', label: 'ShpA FB Mix', defaultValue: 0, bipolar: false, safeMax: 0.7 }, { id: 81, name: 'Shp_A_Ring_Mod', label: 'ShpA Ring', defaultValue: 0, bipolar: false }, // --- Shaper B (6) --- @@ -69,48 +73,48 @@ export const SYNTH_PARAM_MAP = [ { id: 104, name: 'Shp_B_Fold', label: 'ShpB Fold', defaultValue: 0.5, bipolar: false }, { id: 105, name: 'Shp_B_Asym', label: 'ShpB Asym', defaultValue: 0, bipolar: false }, { id: 106, name: 'Shp_B_Mix', label: 'ShpB Mix', defaultValue: 0, bipolar: true }, - { id: 108, name: 'Shp_B_FB_Mix', label: 'ShpB FB Mix', defaultValue: 0, bipolar: false }, + { id: 108, name: 'Shp_B_FB_Mix', label: 'ShpB FB Mix', defaultValue: 0, bipolar: false, safeMax: 0.7 }, { id: 111, name: 'Shp_B_Ring_Mod', label: 'ShpB Ring', defaultValue: 0, bipolar: false }, // --- Comb Filter (8) --- { id: 113, name: 'Comb_Flt_In_A_B', label: 'Comb In A/B', defaultValue: 0, bipolar: false }, { id: 115, name: 'Comb_Flt_Pitch', label: 'Comb Pitch', defaultValue: 0.5, bipolar: false }, - { id: 119, name: 'Comb_Flt_Decay', label: 'Comb Decay', defaultValue: 0, bipolar: true }, + { id: 119, name: 'Comb_Flt_Decay', label: 'Comb Decay', defaultValue: 0, bipolar: true, safeMin: 0.2, safeMax: 0.8 }, { id: 123, name: 'Comb_Flt_AP_Tune', label: 'Comb AP Tune', defaultValue: 1, bipolar: false }, - { id: 127, name: 'Comb_Flt_AP_Res', label: 'Comb AP Res', defaultValue: 0.5, bipolar: false }, + { id: 127, name: 'Comb_Flt_AP_Res', label: 'Comb AP Res', defaultValue: 0.5, bipolar: false, safeMax: 0.8 }, { id: 129, name: 'Comb_Flt_LP_Tune', label: 'Comb LP Tune', defaultValue: 1, bipolar: false }, - { id: 133, name: 'Comb_Flt_PM', label: 'Comb PM', defaultValue: 0, bipolar: true }, + { id: 133, name: 'Comb_Flt_PM', label: 'Comb PM', defaultValue: 0, bipolar: true, safeMin: 0.2, safeMax: 0.8 }, { id: 135, name: 'Comb_Flt_PM_A_B', label: 'Comb PM A/B', defaultValue: 0, bipolar: false }, // --- State Variable Filter (9) --- { id: 136, name: 'SV_Flt_In_A_B', label: 'SVF In A/B', defaultValue: 0, bipolar: false }, { id: 138, name: 'SV_Flt_Comb_Mix', label: 'SVF CombMix', defaultValue: 0, bipolar: true }, { id: 140, name: 'SV_Flt_Cut', label: 'SVF Cutoff', defaultValue: 0.5, bipolar: false }, - { id: 144, name: 'SV_Flt_Res', label: 'SVF Reso', defaultValue: 0.5, bipolar: false }, + { id: 144, name: 'SV_Flt_Res', label: 'SVF Reso', defaultValue: 0.5, bipolar: false, safeMax: 0.8 }, { id: 148, name: 'SV_Flt_Spread', label: 'SVF Spread', defaultValue: 0.2, bipolar: true }, { id: 150, name: 'SV_Flt_LBH', label: 'SVF L/B/H', defaultValue: 0, bipolar: false }, { id: 152, name: 'SV_Flt_Par', label: 'SVF Par', defaultValue: 0, bipolar: true }, - { id: 153, name: 'SV_Flt_FM', label: 'SVF FM', defaultValue: 0, bipolar: true }, + { id: 153, name: 'SV_Flt_FM', label: 'SVF FM', defaultValue: 0, bipolar: true, safeMin: 0.15, safeMax: 0.85 }, { id: 155, name: 'SV_Flt_FM_A_B', label: 'SVF FM A/B', defaultValue: 0, bipolar: false }, // --- Gap Filter (6) --- { id: 201, name: 'Gap_Flt_Center', label: 'Gap Center', defaultValue: 0.5, bipolar: false }, { id: 203, name: 'Gap_Flt_Stereo', label: 'Gap Stereo', defaultValue: 0, bipolar: true }, { id: 204, name: 'Gap_Flt_Gap', label: 'Gap Width', defaultValue: 0.125, bipolar: false }, - { id: 206, name: 'Gap_Flt_Res', label: 'Gap Res', defaultValue: 0.5, bipolar: false }, + { id: 206, name: 'Gap_Flt_Res', label: 'Gap Res', defaultValue: 0.5, bipolar: false, safeMax: 0.8 }, { id: 207, name: 'Gap_Flt_Bal', label: 'Gap Bal', defaultValue: 0, bipolar: true }, { id: 209, name: 'Gap_Flt_Mix', label: 'Gap Mix', defaultValue: 0, bipolar: true }, // --- Feedback Mixer (9) --- - { id: 156, name: 'FB_Mix_Comb', label: 'FB Comb', defaultValue: 0, bipolar: true }, - { id: 158, name: 'FB_Mix_SVF', label: 'FB SVF', defaultValue: 0, bipolar: true }, - { id: 160, name: 'FB_Mix_FX', label: 'FB FX', defaultValue: 0, bipolar: true }, - { id: 162, name: 'FB_Mix_Rvb', label: 'FB Reverb', defaultValue: 0.5, bipolar: false }, - { id: 164, name: 'FB_Mix_Drive', label: 'FB Drive', defaultValue: 0.286, bipolar: false, safeMax: 0.5 }, + { id: 156, name: 'FB_Mix_Comb', label: 'FB Comb', defaultValue: 0, bipolar: true, safeMin: 0.25, safeMax: 0.75 }, + { id: 158, name: 'FB_Mix_SVF', label: 'FB SVF', defaultValue: 0, bipolar: true, safeMin: 0.25, safeMax: 0.75 }, + { id: 160, name: 'FB_Mix_FX', label: 'FB FX', defaultValue: 0, bipolar: true, safeMin: 0.25, safeMax: 0.75 }, + { id: 162, name: 'FB_Mix_Rvb', label: 'FB Reverb', defaultValue: 0.5, bipolar: false, safeMax: 0.75 }, + { id: 164, name: 'FB_Mix_Drive', label: 'FB Drive', defaultValue: 0.286, bipolar: false, safeMax: 0.4 }, { id: 166, name: 'FB_Mix_Fold', label: 'FB Fold', defaultValue: 0.5, bipolar: false }, { id: 167, name: 'FB_Mix_Asym', label: 'FB Asym', defaultValue: 0, bipolar: false }, - { id: 299, name: 'FB_Mix_Lvl', label: 'FB Level', defaultValue: 0.38, bipolar: false }, - { id: 346, name: 'FB_Mix_Osc', label: 'FB Osc', defaultValue: 0, bipolar: true }, + { id: 299, name: 'FB_Mix_Lvl', label: 'FB Level', defaultValue: 0.38, bipolar: false, safeMax: 0.55 }, + { id: 346, name: 'FB_Mix_Osc', label: 'FB Osc', defaultValue: 0, bipolar: true, safeMin: 0.25, safeMax: 0.75 }, // --- Output Mixer (14) --- { id: 169, name: 'Out_Mix_A_Lvl', label: 'Out A Lvl', defaultValue: 0.75, bipolar: true, safeMin: 0.15, safeMax: 0.85 }, @@ -122,11 +126,11 @@ export const SYNTH_PARAM_MAP = [ { id: 178, name: 'Out_Mix_SVF_Lvl', label: 'Out SVF Lvl', defaultValue: 0, bipolar: true, safeMin: 0.15, safeMax: 0.85 }, { id: 180, name: 'Out_Mix_SVF_Pan', label: 'Out SVF Pan', defaultValue: 0, bipolar: true }, { id: 181, name: 'Out_Mix_Drive', label: 'Out Drive', defaultValue: 0, bipolar: false, safeMax: 0.5 }, - { id: 183, name: 'Out_Mix_Fold', label: 'Out Fold', defaultValue: 0.5, bipolar: false }, + { id: 183, name: 'Out_Mix_Fold', label: 'Out Fold', defaultValue: 0.5, bipolar: false, safeMax: 0.75 }, { id: 184, name: 'Out_Mix_Asym', label: 'Out Asym', defaultValue: 0, bipolar: false }, { id: 185, name: 'Out_Mix_Lvl', label: 'Out Level', defaultValue: 0.38, bipolar: false, safeMin: 0.15, safeMax: 0.75 }, { id: 187, name: 'Out_Mix_Key_Pan', label: 'Out KeyPan', defaultValue: 0, bipolar: false }, - { id: 362, name: 'Out_Mix_To_FX', label: 'Out ToFX', defaultValue: 0, bipolar: false }, + { id: 362, name: 'Out_Mix_To_FX', label: 'Out ToFX', defaultValue: 0, bipolar: false, safeMax: 0.8 }, // --- Cabinet (8) --- { id: 188, name: 'Cabinet_Drive', label: 'Cab Drive', defaultValue: 0.4, bipolar: false, safeMax: 0.6 }, @@ -135,7 +139,7 @@ export const SYNTH_PARAM_MAP = [ { id: 192, name: 'Cabinet_Tilt', label: 'Cab Tilt', defaultValue: 0.5, bipolar: true }, { id: 194, name: 'Cabinet_Hi_Cut', label: 'Cab HiCut', defaultValue: 0.625, bipolar: false }, { id: 196, name: 'Cabinet_Lo_Cut', label: 'Cab LoCut', defaultValue: 0.125, bipolar: false }, - { id: 197, name: 'Cabinet_Cab_Lvl', label: 'Cab Level', defaultValue: 0.72, bipolar: false }, + { id: 197, name: 'Cabinet_Cab_Lvl', label: 'Cab Level', defaultValue: 0.72, bipolar: false, safeMax: 0.85 }, { id: 199, name: 'Cabinet_Mix', label: 'Cab Mix', defaultValue: 0, bipolar: false }, // --- Flanger (13) --- @@ -144,8 +148,8 @@ export const SYNTH_PARAM_MAP = [ { id: 214, name: 'Flanger_Rate', label: 'Flng Rate', defaultValue: 0.317, bipolar: false }, { id: 216, name: 'Flanger_Time', label: 'Flng Time', defaultValue: 0.317, bipolar: false }, { id: 218, name: 'Flanger_Stereo', label: 'Flng Stereo', defaultValue: 0, bipolar: true }, - { id: 219, name: 'Flanger_Feedback', label: 'Flng FB', defaultValue: 0, bipolar: true }, - { id: 221, name: 'Flanger_Cross_FB', label: 'Flng XFB', defaultValue: 0.5, bipolar: true }, + { id: 219, name: 'Flanger_Feedback', label: 'Flng FB', defaultValue: 0, bipolar: true, safeMin: 0.15, safeMax: 0.85 }, + { id: 221, name: 'Flanger_Cross_FB', label: 'Flng XFB', defaultValue: 0.5, bipolar: true, safeMin: 0.2, safeMax: 0.8 }, { id: 222, name: 'Flanger_Hi_Cut', label: 'Flng HiCut', defaultValue: 0.75, bipolar: false }, { id: 223, name: 'Flanger_Mix', label: 'Flng Mix', defaultValue: 0, bipolar: true }, { id: 307, name: 'Flanger_Envelope', label: 'Flng Env', defaultValue: 0, bipolar: false }, @@ -157,9 +161,9 @@ export const SYNTH_PARAM_MAP = [ { id: 225, name: 'Echo_Time', label: 'Echo Time', defaultValue: 0.433, bipolar: false }, { id: 227, name: 'Echo_Stereo', label: 'Echo Stereo', defaultValue: 0, bipolar: true }, { id: 229, name: 'Echo_Feedback', label: 'Echo FB', defaultValue: 0.5, bipolar: false, safeMax: 0.75 }, - { id: 231, name: 'Echo_Cross_FB', label: 'Echo XFB', defaultValue: 0.5, bipolar: false }, + { id: 231, name: 'Echo_Cross_FB', label: 'Echo XFB', defaultValue: 0.5, bipolar: false, safeMax: 0.7 }, { id: 232, name: 'Echo_Hi_Cut', label: 'Echo HiCut', defaultValue: 0.75, bipolar: false }, - { id: 233, name: 'Echo_Mix', label: 'Echo Mix', defaultValue: 0, bipolar: false }, + { id: 233, name: 'Echo_Mix', label: 'Echo Mix', defaultValue: 0, bipolar: false, safeMax: 0.7 }, { id: 342, name: 'Echo_Send', label: 'Echo Send', defaultValue: 1, bipolar: false }, // --- Reverb (6) --- @@ -167,11 +171,11 @@ export const SYNTH_PARAM_MAP = [ { id: 237, name: 'Reverb_Pre_Dly', label: 'Verb PreDly', defaultValue: 0.33, bipolar: false }, { id: 238, name: 'Reverb_Color', label: 'Verb Color', defaultValue: 0.5, bipolar: false }, { id: 240, name: 'Reverb_Chorus', label: 'Verb Chorus', defaultValue: 0.25, bipolar: false }, - { id: 241, name: 'Reverb_Mix', label: 'Verb Mix', defaultValue: 0, bipolar: false }, - { id: 344, name: 'Reverb_Send', label: 'Verb Send', defaultValue: 1, bipolar: false }, + { id: 241, name: 'Reverb_Mix', label: 'Verb Mix', defaultValue: 0, bipolar: false, safeMax: 0.7 }, + { id: 344, name: 'Reverb_Send', label: 'Verb Send', defaultValue: 1, bipolar: false, safeMax: 0.85 }, // --- Unison (3) --- - { id: 250, name: 'Unison_Detune', label: 'Uni Detune', defaultValue: 0.004, bipolar: false }, + { id: 250, name: 'Unison_Detune', label: 'Uni Detune', defaultValue: 0.004, bipolar: false, safeMax: 0.5 }, { id: 252, name: 'Unison_Phase', label: 'Uni Phase', defaultValue: 0, bipolar: false }, { id: 253, name: 'Unison_Pan', label: 'Uni Pan', defaultValue: 0, bipolar: false }, @@ -266,3 +270,35 @@ export function applyTame(rawValue, paramEntry, tameLevel) { return effectiveMin + rawValue * (effectiveMax - effectiveMin); } + +// --- Group overrides: per-parameter min/max and per-group curve --- + +/** + * Apply a power curve to remap a [0,1] value. + * curveFactor 0 = logarithmic (steep at start, flat at end) + * curveFactor 0.5 = linear (no change) + * curveFactor 1 = exponential (flat at start, steep at end) + * + * Internally maps curveFactor to an exponent: exp = 2^(4*(curve-0.5)) + * curve=0 -> exp=0.25 (log-ish) + * curve=0.5 -> exp=1 (linear) + * curve=1 -> exp=4 (exp-ish) + */ +export function applyCurve(value, curveFactor) { + if (curveFactor === 0.5) return value; + const exponent = Math.pow(2, 4 * (curveFactor - 0.5)); + return Math.pow(Math.max(0, Math.min(1, value)), exponent); +} + +/** + * Apply group override (curve + min/max) to a raw ML output. + * @param {number} rawValue - ML output in [0, 1] + * @param {number} curve - curve factor [0, 1], 0.5 = linear + * @param {number} min - output range minimum [0, 1] + * @param {number} max - output range maximum [0, 1] + * @returns {number} remapped value + */ +export function applyGroupOverride(rawValue, curve, min, max) { + const curved = applyCurve(rawValue, curve); + return min + curved * (max - min); +}