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)
This commit is contained in:
w1n5t0n 2026-03-22 21:57:21 +02:00
parent 21832e0099
commit affec8f604
8 changed files with 994 additions and 58 deletions

9
playground/TODOS.md Normal file
View file

@ -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

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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 = '<div class="group-drawer-header"></div><div class="group-drawer-body"></div>';
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);

View file

@ -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());
}
});

View file

@ -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());
}
});

View file

@ -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);
}