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:
parent
21832e0099
commit
affec8f604
8 changed files with 994 additions and 58 deletions
9
playground/TODOS.md
Normal file
9
playground/TODOS.md
Normal 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
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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,12 +1229,17 @@ 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) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue