diff --git a/playground/js/a-app.js b/playground/js/a-app.js index 91b6ac7..c7ca070 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -3,7 +3,7 @@ import { WasmIML } from './nisps/nisps-wasm.js'; import { FlowFieldVisualizer } from './ui/visualizer.js'; -import { AudioCanvas, N_AUDIO_OUTPUTS, AUDIO_PARAM_NAMES, AUDIO_PARAM_COLORS } from './audio/audio-canvas.js'; +import { AudioCanvas } from './audio/audio-canvas.js'; import { C15Adapter } from './synth/c15-adapter.js'; import { Arpeggiator } from './synth/arpeggiator.js'; import { MIDIInput } from './synth/midi-input.js'; @@ -30,7 +30,7 @@ const N_HAND_INPUTS = 14; const N_INPUTS = N_JOY_INPUTS; // default (joystick) const N_VISUAL_OUTPUTS = 20; const N_SYNTH_OUTPUTS = SYNTH_PARAM_MAP.length; // 126 -// N_AUDIO_OUTPUTS = 36 — imported from audio-canvas.js +// Audio canvas output count is dynamic — queried from audioCanvas.getOutputCount() let N_OUTPUTS = N_SYNTH_OUTPUTS; // Dynamic — changes with output mode let audioCanvas = null; // lazily created on first switch to audio-canvas mode @@ -193,9 +193,14 @@ const visualOverrides = VISUAL_PARAM_NAMES.map(() => ({ })); // ---- Audio Canvas Overrides ---- -const audioCanvasOverrides = Array.from({ length: N_AUDIO_OUTPUTS }, () => ({ - min: 0, max: 1, curve: 0.5, frozen: false, fixedValue: 0.5, -})); +// Dynamic array — resized when AudioCanvas output count changes +let audioCanvasOverrides = []; +function _ensureAudioCanvasOverrides(count) { + while (audioCanvasOverrides.length < count) { + audioCanvasOverrides.push({ min: 0, max: 1, curve: 0.5, frozen: false, fixedValue: 0.5 }); + } + if (audioCanvasOverrides.length > count) audioCanvasOverrides.length = count; +} // ---- Synth Sections (for SynthVisualizer) ---- const SYNTH_SECTIONS = [ @@ -958,7 +963,7 @@ function outputCountForMode(mode) { if (mode === 'visual') return N_VISUAL_OUTPUTS; if (mode === 'synth') return N_SYNTH_OUTPUTS; if (mode === 'midi-cc') return midiCCMap.length; - if (mode === 'audio-canvas') return N_AUDIO_OUTPUTS; + if (mode === 'audio-canvas') return audioCanvas ? audioCanvas.getOutputCount() : 12; return N_SYNTH_OUTPUTS; } @@ -1576,9 +1581,20 @@ function buildHeatmap() { names = meta.map(p => p.name); colors = useCuratedColors ? SYNTH_PARAM_COLORS : meta.map(p => _colorFromGroup(p.group)); } else { - count = isMidiCC ? midiCCMap.length : isAudioCanvas ? N_AUDIO_OUTPUTS : N_VISUAL_OUTPUTS; - names = isMidiCC ? midiCCMap.map(p => p.name) : isAudioCanvas ? AUDIO_PARAM_NAMES : VISUAL_PARAM_NAMES; - colors = isMidiCC ? MIDI_CC_PARAM_COLORS : isAudioCanvas ? AUDIO_PARAM_COLORS : VISUAL_PARAM_COLORS; + if (isAudioCanvas && audioCanvas) { + count = audioCanvas.getOutputCount(); + names = audioCanvas.getAudioParamNames(); + colors = audioCanvas.getAudioParamColors(); + _ensureAudioCanvasOverrides(count); + } else if (isMidiCC) { + count = midiCCMap.length; + names = midiCCMap.map(p => p.name); + colors = MIDI_CC_PARAM_COLORS; + } else { + count = N_VISUAL_OUTPUTS; + names = VISUAL_PARAM_NAMES; + colors = VISUAL_PARAM_COLORS; + } } for (let i = 0; i < count; i++) { @@ -1670,8 +1686,9 @@ function setHeatmapValue(paramIndex, e, cell) { paramName = activeEngine.paramMeta[paramIndex].name; } else if (isMidiCC) { paramName = midiCCMap[paramIndex]?.name ?? `p${paramIndex}`; - } else if (isAudioCanvas) { - paramName = AUDIO_PARAM_NAMES[paramIndex] ?? `p${paramIndex}`; + } else if (isAudioCanvas && audioCanvas) { + const acNames = audioCanvas.getAudioParamNames(); + paramName = acNames[paramIndex] ?? `p${paramIndex}`; } else { paramName = VISUAL_PARAM_NAMES[paramIndex] ?? `p${paramIndex}`; } @@ -1766,9 +1783,11 @@ function showParamPopup(paramIndex, cell) { } else if (isMidiCC) { name = midiCCMap[paramIndex]?.name ?? `p${paramIndex}`; color = MIDI_CC_PARAM_COLORS[paramIndex] ?? '#888'; - } else if (isAudioCanvas) { - name = AUDIO_PARAM_NAMES[paramIndex] ?? `p${paramIndex}`; - color = AUDIO_PARAM_COLORS[paramIndex] ?? '#888'; + } else if (isAudioCanvas && audioCanvas) { + const acNames = audioCanvas.getAudioParamNames(); + const acColors = audioCanvas.getAudioParamColors(); + name = acNames[paramIndex] ?? `p${paramIndex}`; + color = acColors[paramIndex] ?? '#888'; } else { name = VISUAL_PARAM_NAMES[paramIndex] ?? `p${paramIndex}`; color = VISUAL_PARAM_COLORS[paramIndex] ?? '#888'; @@ -2612,6 +2631,18 @@ async function setOutputMode(mode, { skipConfirm = false } = {}) { // Lazily create AudioCanvas on first switch if (mode === 'audio-canvas' && !audioCanvas) { audioCanvas = new AudioCanvas(audioCanvasWrap); + _ensureAudioCanvasOverrides(audioCanvas.getOutputCount()); + // Listen for dynamic output count changes (loop count changes) + audioCanvasWrap.addEventListener('ac:outputcount-changed', async (e) => { + if (outputMode !== 'audio-canvas') return; + const newCount = e.detail.count; + _ensureAudioCanvasOverrides(newCount); + if (newCount !== N_OUTPUTS) { + await resizeMLP(newCount); + } + buildHeatmap(); + updateHeatmap(iml.getOutputs()); + }); } // Hide/show audio-canvas wrap diff --git a/playground/js/audio/audio-canvas.js b/playground/js/audio/audio-canvas.js index 32f27e9..ba99707 100644 --- a/playground/js/audio/audio-canvas.js +++ b/playground/js/audio/audio-canvas.js @@ -1,14 +1,18 @@ // audio-canvas.js — Audio Canvas mode for NISPS Immersive -// Infinite white canvas, drag-and-drop audio files, NISPS maps joystick→36 audio params +// Infinite white canvas, drag-and-drop audio files, NISPS maps joystick->audio params +// Per-clip configurable loop count (0-4), dynamic output sizing +// Beat-synced loop length with per-clip overrides +// Param range groups with dual-thumb sliders // 4 submodes: remix, granular, drone, slicer -export const N_AUDIO_OUTPUTS = 36; +// Output layout (dynamic): +// [0..7] global param ranges: vol min/max, pitch min/max, pos min/max, filt min/max +// [8..] per-loop params, packed contiguously: [vol, pitch, pos, filt] x totalLoops +// Loop order follows clip order (clip 0's loops first, then clip 1's, etc.) -// Output layout (indices 0–35): -// [0..8] clip volumes / grain density / slicer clip selector -// [9..17] clip pitch/speed / grain pitch / slicer position -// [18..26] clip loop start / grain position / slicer speed -// [27..35] clip filter cutoff / grain size / unused +const N_RANGE_OUTPUTS = 8; // reserved range endpoint slots +const N_PARAMS_PER_LOOP = 4; // volume, pitch, position, filter +const MAX_LOOPS_PER_CLIP = 4; const SUBMODES = ['remix', 'granular', 'drone', 'slicer']; const CELL_W = 220; @@ -16,19 +20,21 @@ const CELL_H = 110; const CELL_GAP = 14; const CELLS_PER_ROW = 3; -// Generate param names for heatmap -export const AUDIO_PARAM_NAMES = [ - ...Array.from({ length: 9 }, (_, i) => `Vol ${i + 1}`), - ...Array.from({ length: 9 }, (_, i) => `Pitch ${i + 1}`), - ...Array.from({ length: 9 }, (_, i) => `Loop ${i + 1}`), - ...Array.from({ length: 9 }, (_, i) => `Filt ${i + 1}`), -]; +// Param group colors +const GROUP_COLORS = { + volume: '#4a90d9', + pitch: '#50c878', + position: '#e88c32', + filter: '#9b59b6', +}; -export const AUDIO_PARAM_COLORS = [ - ...Array.from({ length: 9 }, (_, i) => `hsl(${210 + i * 8}, 70%, 55%)`), // blues - volumes - ...Array.from({ length: 9 }, (_, i) => `hsl(${120 + i * 8}, 60%, 50%)`), // greens - pitch - ...Array.from({ length: 9 }, (_, i) => `hsl(${30 + i * 8}, 75%, 55%)`), // oranges - loop - ...Array.from({ length: 9 }, (_, i) => `hsl(${280 + i * 8}, 60%, 60%)`), // purples - filter +// Range endpoint placeholder names/colors (first 8 outputs) +const RANGE_NAMES = ['Vol Min', 'Vol Max', 'Pitch Min', 'Pitch Max', 'Pos Min', 'Pos Max', 'Filt Min', 'Filt Max']; +const RANGE_COLORS = [ + GROUP_COLORS.volume, GROUP_COLORS.volume, + GROUP_COLORS.pitch, GROUP_COLORS.pitch, + GROUP_COLORS.position, GROUP_COLORS.position, + GROUP_COLORS.filter, GROUP_COLORS.filter, ]; function cellPosition(index) { @@ -43,25 +49,44 @@ export class AudioCanvas { this._audioCtx = null; this._masterGain = null; this._clips = []; // { id, name, buffer, duration, waveformData, _cell, _overviewCanvas, _detailCanvas, _statusBar } - this._players = []; // per-clip player state + this._loopCounts = []; // per-clip loop count (0-4), default 1 + this._loopPlayers = []; // per-clip array of player objects (one per active loop) this._cellView = []; // 'overview' | 'detail' per clip - this._granularTimers = []; + this._granularTimers = []; // per-loop flat granular timers this._slicerTimer = null; this._slicerStep = 0; this._loopUpdateTimer = null; this._submode = 'remix'; this._beatSync = false; this._bpm = 120; + this._loopLengthUnit = 'bars'; // 'bars' | 'beats' + this._loopLengthValue = 4; // one of: 2, 4, 8, 16, 32, 64 + this._perLoopLengthOverrides = new Map(); // clipIndex -> { unit, value } or null + this._lockPitch = false; // when true, playback rate stays fixed (no ML-driven pitch) this._running = false; - this._outputs = new Float32Array(N_AUDIO_OUTPUTS).fill(0.5); + this._outputs = new Float32Array(this.getOutputCount()).fill(0.5); + this._loopOutputs = []; // per-loop position values (after range outputs) this._panX = 40; this._panY = 40; this._zoom = 1; + this._lastLoopCountChange = null; // for revert support + + // Global param ranges (from ML outputs 0-7) + this._paramRanges = { + volume: { min: 0, max: 1 }, + pitch: { min: 0, max: 1 }, + position: { min: 0, max: 1 }, + filter: { min: 0, max: 1 }, + }; + // Manual override flags -- when true, ML outputs for that range are ignored + this._rangeOverrides = { + volume: false, pitch: false, position: false, filter: false, + }; this._setupDOM(); } - // ── DOM setup ────────────────────────────────────────────────────────────── + // -- DOM setup --------------------------------------------------------------- _setupDOM() { this._container.innerHTML = ''; @@ -104,6 +129,9 @@ export class AudioCanvas { `; this._container.appendChild(this._dropOverlay); + // Param range panel + this._buildRangePanel(); + // Control bar this._buildControlBar(); @@ -124,6 +152,180 @@ export class AudioCanvas { this._setupPanZoom(); } + // -- Range panel (dual-thumb sliders) ---------------------------------------- + + _buildRangePanel() { + const panel = document.createElement('div'); + panel.className = 'ac-range-panel'; + panel.style.cssText = ` + position: absolute; top: 12px; left: 50%; transform: translateX(-50%); + background: rgba(20,20,20,0.88); backdrop-filter: blur(10px); + border-radius: 14px; padding: 10px 16px 8px; + font-family: 'JetBrains Mono', monospace; font-size: 0.62rem; + color: #ccc; z-index: 9; + box-shadow: 0 4px 20px rgba(0,0,0,0.35); + display: none; min-width: 260px; max-width: 340px; + touch-action: none; user-select: none; + `; + this._container.appendChild(panel); + this._rangePanel = panel; + this._rangeSliders = {}; + + const groups = [ + { key: 'volume', label: 'Volume', color: GROUP_COLORS.volume }, + { key: 'pitch', label: 'Pitch', color: GROUP_COLORS.pitch }, + { key: 'position', label: 'Position', color: GROUP_COLORS.position }, + { key: 'filter', label: 'Filter', color: GROUP_COLORS.filter }, + ]; + + for (const g of groups) { + const row = document.createElement('div'); + row.style.cssText = `display:flex;align-items:center;gap:8px;margin-bottom:4px;height:20px;`; + + // Label + const label = document.createElement('span'); + label.style.cssText = `width:56px;text-align:right;color:${g.color};font-size:0.58rem;flex-shrink:0;`; + label.textContent = g.label; + + // Track container + const track = document.createElement('div'); + track.style.cssText = ` + position:relative; flex:1; height:8px; background:rgba(255,255,255,0.08); + border-radius:4px; cursor:pointer; + `; + + // Filled range + const fill = document.createElement('div'); + fill.style.cssText = ` + position:absolute; top:0; height:100%; border-radius:4px; + background:${g.color}; opacity:0.35; pointer-events:none; + `; + track.appendChild(fill); + + // Min thumb + const minThumb = this._createThumb(g.color); + track.appendChild(minThumb); + + // Max thumb + const maxThumb = this._createThumb(g.color); + track.appendChild(maxThumb); + + // Override indicator dot + const overrideDot = document.createElement('div'); + overrideDot.style.cssText = ` + width:5px;height:5px;border-radius:50%;background:${g.color}; + opacity:0;transition:opacity 0.15s;flex-shrink:0; + `; + + // Value text + const valueText = document.createElement('span'); + valueText.style.cssText = `width:72px;text-align:left;color:#888;font-size:0.56rem;flex-shrink:0;`; + valueText.textContent = '0.00 - 1.00'; + + row.appendChild(label); + row.appendChild(overrideDot); + row.appendChild(track); + row.appendChild(valueText); + panel.appendChild(row); + + this._rangeSliders[g.key] = { track, fill, minThumb, maxThumb, valueText, overrideDot }; + + // Drag handling for thumbs + this._setupThumbDrag(g.key, minThumb, 'min', track); + this._setupThumbDrag(g.key, maxThumb, 'max', track); + + // Double-click track to clear override + track.addEventListener('dblclick', (e) => { + e.preventDefault(); + this._rangeOverrides[g.key] = false; + this._updateRangePanel(); + }); + } + + this._updateRangePanel(); + } + + _createThumb(color) { + const thumb = document.createElement('div'); + thumb.style.cssText = ` + position:absolute; top:50%; width:14px; height:14px; + border-radius:50%; background:${color}; border:2px solid rgba(255,255,255,0.9); + transform:translate(-50%,-50%); cursor:grab; z-index:2; + box-shadow:0 1px 4px rgba(0,0,0,0.4); transition:box-shadow 0.1s; + `; + return thumb; + } + + _setupThumbDrag(groupKey, thumb, which, track) { + let dragging = false; + + const onMove = (e) => { + if (!dragging) return; + e.preventDefault(); + const rect = track.getBoundingClientRect(); + const x = (e.touches ? e.touches[0].clientX : e.clientX); + const norm = Math.max(0, Math.min(1, (x - rect.left) / rect.width)); + this._paramRanges[groupKey][which] = norm; + this._rangeOverrides[groupKey] = true; + this._updateRangePanel(); + }; + + const onUp = () => { + dragging = false; + thumb.style.cursor = 'grab'; + thumb.style.boxShadow = '0 1px 4px rgba(0,0,0,0.4)'; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onUp); + }; + + const onDown = (e) => { + e.preventDefault(); + e.stopPropagation(); + dragging = true; + thumb.style.cursor = 'grabbing'; + thumb.style.boxShadow = '0 2px 8px rgba(0,0,0,0.6)'; + document.addEventListener('mousemove', onMove, { passive: false }); + document.addEventListener('mouseup', onUp); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onUp); + }; + + thumb.addEventListener('mousedown', onDown); + thumb.addEventListener('touchstart', onDown, { passive: false }); + } + + _updateRangePanel() { + if (!this._rangePanel) return; + // Show panel only when there are clips + this._rangePanel.style.display = this._clips.length > 0 ? 'block' : 'none'; + + for (const group of ['volume', 'pitch', 'position', 'filter']) { + const s = this._rangeSliders[group]; + if (!s) continue; + const r = this._paramRanges[group]; + const lo = Math.min(r.min, r.max); + const hi = Math.max(r.min, r.max); + + // Position thumbs + s.minThumb.style.left = `${r.min * 100}%`; + s.maxThumb.style.left = `${r.max * 100}%`; + + // Fill bar between thumbs + s.fill.style.left = `${lo * 100}%`; + s.fill.style.width = `${(hi - lo) * 100}%`; + + // Value text + s.valueText.textContent = `${lo.toFixed(2)} \u2013 ${hi.toFixed(2)}`; + + // Override indicator + s.overrideDot.style.opacity = this._rangeOverrides[group] ? '1' : '0'; + } + } + + // -- Control bar ------------------------------------------------------------- + _buildControlBar() { const bar = document.createElement('div'); bar.style.cssText = ` @@ -160,6 +362,42 @@ export class AudioCanvas { text-align:center; "> +