From b5b90a623dfecb895150f899011a5c978c4a823a Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Thu, 2 Apr 2026 22:12:18 +0100 Subject: [PATCH] =?UTF-8?q?feat(playground):=20audio-canvas=20mode=20?= =?UTF-8?q?=E2=80=94=20NISPS-driven=20generative=20sampler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New output mode in a-immersive with 4 submodes: - Remix: per-clip volume/loop-start/filter, joystick shapes mix - Granular: grain density/pitch/position/size per clip - Drone: ultra-slow playback (0.005–0.1×), spectral texture - Slicer: beat-synced 8-step slice sequencer, NISPS picks clip/position/speed Canvas features: - Infinite pan/zoom canvas (pointer + pinch + wheel) - Drag-and-drop audio files (MP3/WAV/FLAC/OGG/M4A) - Grid + overflow layout (3×3 cells, expands automatically) - Waveform cells with overview + detail view (tap to toggle) - Loop region highlight, live level metering, per-cell glow feedback - Beat sync toggle (free vs. BPM-locked) with configurable BPM - 36 NISPS outputs: vol×9, pitch×9, loop×9, filter×9 --- playground/a-immersive.html | 4 + playground/js/a-app.js | 51 +- playground/js/audio/audio-canvas.js | 801 ++++++++++++++++++++++++++++ 3 files changed, 850 insertions(+), 6 deletions(-) create mode 100644 playground/js/audio/audio-canvas.js diff --git a/playground/a-immersive.html b/playground/a-immersive.html index c4ee884..d1e1e5a 100644 --- a/playground/a-immersive.html +++ b/playground/a-immersive.html @@ -70,6 +70,9 @@ + + + @@ -206,6 +209,7 @@ +
diff --git a/playground/js/a-app.js b/playground/js/a-app.js index d820aa9..5e9039f 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -3,6 +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 { C15Bridge } from './synth/c15-bridge.js'; import { Arpeggiator } from './synth/arpeggiator.js'; import { MIDIInput } from './synth/midi-input.js'; @@ -21,7 +22,10 @@ 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 let N_OUTPUTS = N_SYNTH_OUTPUTS; // Dynamic — changes with output mode + +let audioCanvas = null; // lazily created on first switch to audio-canvas mode const STORAGE_KEY = 'nisps-a-immersive'; const VISUAL_PARAM_NAMES = [ @@ -141,6 +145,11 @@ const visualOverrides = VISUAL_PARAM_NAMES.map(() => ({ fixedValue: 0.5, })); +// ---- Audio Canvas Overrides ---- +const audioCanvasOverrides = Array.from({ length: N_AUDIO_OUTPUTS }, () => ({ + min: 0, max: 1, curve: 0.5, frozen: false, fixedValue: 0.5, +})); + // ---- Synth Sections (for SynthVisualizer) ---- const SYNTH_SECTIONS = [ { name: 'Env A', count: 7, color: '#4488ff' }, @@ -709,6 +718,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; return N_SYNTH_OUTPUTS; } @@ -868,9 +878,10 @@ function buildHeatmap() { $heatmapCells.innerHTML = ''; const isSynth = outputMode === 'synth'; const isMidiCC = outputMode === 'midi-cc'; - const count = isMidiCC ? midiCCMap.length : isSynth ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS; - const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : VISUAL_PARAM_NAMES; - const colors = isMidiCC ? MIDI_CC_PARAM_COLORS : isSynth ? SYNTH_PARAM_COLORS : VISUAL_PARAM_COLORS; + const isAudioCanvas = outputMode === 'audio-canvas'; + const count = isMidiCC ? midiCCMap.length : isSynth ? N_SYNTH_OUTPUTS : isAudioCanvas ? N_AUDIO_OUTPUTS : N_VISUAL_OUTPUTS; + const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : isAudioCanvas ? AUDIO_PARAM_NAMES : VISUAL_PARAM_NAMES; + const colors = isMidiCC ? MIDI_CC_PARAM_COLORS : isSynth ? SYNTH_PARAM_COLORS : isAudioCanvas ? AUDIO_PARAM_COLORS : VISUAL_PARAM_COLORS; for (let i = 0; i < count; i++) { const cell = document.createElement('div'); @@ -955,7 +966,8 @@ function setHeatmapValue(paramIndex, e, cell) { // Update tooltip const isSynth = outputMode === 'synth'; const isMidiCC = outputMode === 'midi-cc'; - const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : VISUAL_PARAM_NAMES; + const isAudioCanvas = outputMode === 'audio-canvas'; + const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : isAudioCanvas ? AUDIO_PARAM_NAMES : VISUAL_PARAM_NAMES; $heatmapTooltip.textContent = `${names[paramIndex]}: ${x.toFixed(3)}`; $heatmapTooltip.classList.add('visible'); $heatmapTooltip.style.left = `${rect.left}px`; @@ -993,6 +1005,8 @@ function getParamOverride(paramIndex) { }; } else if (outputMode === 'midi-cc') { return midiCCOverrides[paramIndex] || null; + } else if (outputMode === 'audio-canvas') { + return audioCanvasOverrides[paramIndex] || null; } else { return visualOverrides[paramIndex] || null; } @@ -1019,8 +1033,9 @@ function showParamPopup(paramIndex, cell) { const isSynth = outputMode === 'synth'; const isMidiCC = outputMode === 'midi-cc'; - const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : VISUAL_PARAM_NAMES; - const colors = isMidiCC ? MIDI_CC_PARAM_COLORS : isSynth ? SYNTH_PARAM_COLORS : VISUAL_PARAM_COLORS; + const isAudioCanvas = outputMode === 'audio-canvas'; + const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : isAudioCanvas ? AUDIO_PARAM_NAMES : VISUAL_PARAM_NAMES; + const colors = isMidiCC ? MIDI_CC_PARAM_COLORS : isSynth ? SYNTH_PARAM_COLORS : isAudioCanvas ? AUDIO_PARAM_COLORS : VISUAL_PARAM_COLORS; const name = names[paramIndex]; const color = colors[paramIndex]; const ov = getParamOverride(paramIndex); @@ -1509,6 +1524,8 @@ function routeOutputs(outputs) { } midiOutput.sendBatch(messages); } + } else if (outputMode === 'audio-canvas') { + if (audioCanvas) audioCanvas.setOutputs(outputs); } else { const vis = new Array(N_VISUAL_OUTPUTS); for (let i = 0; i < N_VISUAL_OUTPUTS; i++) { @@ -1795,6 +1812,15 @@ async function setOutputMode(mode, { skipConfirm = false } = {}) { const heatmapStrip = document.getElementById('heatmap-strip'); const synthQuickControls = document.getElementById('synth-quick-controls'); const midiCCQuickControls = document.getElementById('midi-cc-quick-controls'); + const audioCanvasWrap = document.getElementById('audio-canvas-wrap'); + + // Lazily create AudioCanvas on first switch + if (mode === 'audio-canvas' && !audioCanvas) { + audioCanvas = new AudioCanvas(audioCanvasWrap); + } + + // Hide/show audio-canvas wrap + if (audioCanvasWrap) audioCanvasWrap.style.display = mode === 'audio-canvas' ? 'block' : 'none'; if (mode === 'synth') { $canvas.classList.add('hidden-canvas'); @@ -1812,6 +1838,13 @@ async function setOutputMode(mode, { skipConfirm = false } = {}) { synthQuickControls.classList.add('hidden'); if (midiCCQuickControls) midiCCQuickControls.classList.remove('hidden'); synthVisualizer.enableInteraction(false); + } else if (mode === 'audio-canvas') { + $canvas.classList.add('hidden-canvas'); + $synthVisCanvas.classList.remove('active'); + heatmapStrip.classList.remove('hidden'); + synthQuickControls.classList.add('hidden'); + if (midiCCQuickControls) midiCCQuickControls.classList.add('hidden'); + synthVisualizer.enableInteraction(false); } else { $canvas.classList.remove('hidden-canvas'); $synthVisCanvas.classList.remove('active'); @@ -3065,6 +3098,7 @@ function saveState() { groupOverrides, visualOverrides, midiCCOverrides, + audioCanvasState: audioCanvas ? audioCanvas.getState() : null, synthPresetId: activeSynthPresetId, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); @@ -3154,6 +3188,11 @@ async function loadState() { activeSynthPresetId = state.synthPresetId; } + // Restore audio canvas state (pan/zoom/submode) + if (state.audioCanvasState && audioCanvas) { + audioCanvas.setState(state.audioCanvasState); + } + // Restore output mode if (state.outputMode && state.outputMode !== outputMode) { await setOutputMode(state.outputMode, { skipConfirm: true }); diff --git a/playground/js/audio/audio-canvas.js b/playground/js/audio/audio-canvas.js new file mode 100644 index 0000000..2493064 --- /dev/null +++ b/playground/js/audio/audio-canvas.js @@ -0,0 +1,801 @@ +// audio-canvas.js — Audio Canvas mode for NISPS Immersive +// Infinite white canvas, drag-and-drop audio files, NISPS maps joystick→36 audio params +// 4 submodes: remix, granular, drone, slicer + +export const N_AUDIO_OUTPUTS = 36; + +// 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 SUBMODES = ['remix', 'granular', 'drone', 'slicer']; +const CELL_W = 220; +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}`), +]; + +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 +]; + +function cellPosition(index) { + const col = index % CELLS_PER_ROW; + const row = Math.floor(index / CELLS_PER_ROW); + return { x: col * (CELL_W + CELL_GAP), y: row * (CELL_H + CELL_GAP) }; +} + +export class AudioCanvas { + constructor(container) { + this._container = container; + this._audioCtx = null; + this._masterGain = null; + this._clips = []; // { id, name, buffer, duration, waveformData, _cell, _overviewCanvas, _detailCanvas, _statusBar } + this._players = []; // per-clip player state + this._cellView = []; // 'overview' | 'detail' per clip + this._granularTimers = []; + this._slicerTimer = null; + this._slicerStep = 0; + this._loopUpdateTimer = null; + this._submode = 'remix'; + this._beatSync = false; + this._bpm = 120; + this._running = false; + this._outputs = new Float32Array(N_AUDIO_OUTPUTS).fill(0.5); + this._panX = 40; + this._panY = 40; + this._zoom = 1; + + this._setupDOM(); + } + + // ── DOM setup ────────────────────────────────────────────────────────────── + + _setupDOM() { + this._container.innerHTML = ''; + Object.assign(this._container.style, { + position: 'absolute', inset: '0', + overflow: 'hidden', + background: '#f0f0f0', + cursor: 'grab', + touchAction: 'none', + }); + + // Infinite canvas div (child elements positioned absolutely) + this._canvas = document.createElement('div'); + this._canvas.style.cssText = ` + position: absolute; top: 0; left: 0; + width: 6000px; height: 6000px; + transform-origin: 0 0; + `; + this._applyTransform(); + this._container.appendChild(this._canvas); + + // Drop overlay + this._dropOverlay = document.createElement('div'); + this._dropOverlay.style.cssText = ` + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + pointer-events: none; + transition: opacity 0.3s; + `; + this._dropOverlay.innerHTML = ` +
+ + + + + +
Drop audio files here
+
MP3, WAV, FLAC, OGG, M4A
+
+ `; + this._container.appendChild(this._dropOverlay); + + // Control bar + this._buildControlBar(); + + // Drag-and-drop + this._container.addEventListener('dragover', e => { + e.preventDefault(); + this._container.style.outline = '2px dashed #007aff'; + }); + this._container.addEventListener('dragleave', () => { + this._container.style.outline = ''; + }); + this._container.addEventListener('drop', e => { + e.preventDefault(); + this._container.style.outline = ''; + this._handleDrop(e.dataTransfer.files); + }); + + this._setupPanZoom(); + } + + _buildControlBar() { + const bar = document.createElement('div'); + bar.style.cssText = ` + position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); + display: flex; align-items: center; gap: 6px; + background: rgba(20,20,20,0.9); color: #fff; + padding: 7px 12px; border-radius: 24px; + font-family: 'JetBrains Mono', monospace; font-size: 0.68rem; + z-index: 10; backdrop-filter: blur(10px); + box-shadow: 0 4px 20px rgba(0,0,0,0.35); + white-space: nowrap; + `; + bar.innerHTML = ` +
+ ${SUBMODES.map(m => ``).join('')} +
+
+
+ + + +
+
+ + 0 clips + `; + this._container.appendChild(bar); + this._controlBar = bar; + + // Submode buttons + bar.querySelectorAll('.ac-sbtn').forEach(btn => { + btn.addEventListener('click', () => this.setSubmode(btn.dataset.submode)); + }); + this._syncSubmodeUI(); + + // Timing buttons + bar.querySelectorAll('.ac-tbtn').forEach(btn => { + btn.addEventListener('click', () => { + this._beatSync = btn.dataset.sync === 'sync'; + bar.querySelectorAll('.ac-tbtn').forEach(b => { + b.style.background = b === btn ? 'rgba(255,255,255,0.1)' : 'transparent'; + b.style.color = b === btn ? '#fff' : '#aaa'; + }); + }); + }); + + bar.querySelector('#ac-bpm').addEventListener('change', e => { + this._bpm = Math.max(40, Math.min(240, parseFloat(e.target.value) || 120)); + }); + + bar.querySelector('#ac-play').addEventListener('click', () => { + if (this._running) this.stop(); else this.start(); + }); + } + + _syncSubmodeUI() { + this._controlBar.querySelectorAll('.ac-sbtn').forEach(btn => { + const active = btn.dataset.submode === this._submode; + btn.style.background = active ? 'rgba(255,255,255,0.15)' : 'transparent'; + btn.style.color = active ? '#fff' : '#aaa'; + }); + } + + _setupPanZoom() { + const pointers = new Map(); + let lastDist = null; + + this._container.addEventListener('pointerdown', e => { + if (e.target.closest('.ac-cell') || e.target.closest('[data-submode]') || + e.target.closest('[data-sync]') || e.target.closest('#ac-play') || + e.target.closest('#ac-bpm')) return; + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + this._container.setPointerCapture(e.pointerId); + if (pointers.size === 1) this._container.style.cursor = 'grabbing'; + }); + + this._container.addEventListener('pointermove', e => { + if (!pointers.has(e.pointerId)) return; + const prev = pointers.get(e.pointerId); + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + + if (pointers.size === 2) { + const pts = [...pointers.values()]; + const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); + if (lastDist !== null) { + this._zoom = Math.max(0.15, Math.min(3, this._zoom * (dist / lastDist))); + this._applyTransform(); + } + lastDist = dist; + } else if (pointers.size === 1) { + this._panX += e.clientX - prev.x; + this._panY += e.clientY - prev.y; + this._applyTransform(); + } + }); + + this._container.addEventListener('pointerup', e => { + pointers.delete(e.pointerId); + if (pointers.size === 0) { + lastDist = null; + this._container.style.cursor = 'grab'; + } + }); + + this._container.addEventListener('wheel', e => { + e.preventDefault(); + this._zoom = Math.max(0.15, Math.min(3, this._zoom * (e.deltaY > 0 ? 0.9 : 1.1))); + this._applyTransform(); + }, { passive: false }); + } + + _applyTransform() { + this._canvas.style.transform = `translate(${this._panX}px, ${this._panY}px) scale(${this._zoom})`; + } + + // ── Audio file handling ──────────────────────────────────────────────────── + + async _handleDrop(files) { + if (!this._audioCtx) await this._initAudio(); + const valid = [...files].filter(f => + f.type.startsWith('audio/') || /\.(mp3|wav|flac|ogg|m4a|aac)$/i.test(f.name)); + for (const file of valid) await this._loadFile(file); + } + + async _loadFile(file) { + let buffer; + try { + const ab = await file.arrayBuffer(); + buffer = await this._audioCtx.decodeAudioData(ab); + } catch (err) { + console.warn('[AudioCanvas] decode failed:', file.name, err); + return; + } + const id = `clip-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const clip = { + id, + name: file.name.replace(/\.[^.]+$/, ''), + buffer, + duration: buffer.duration, + waveformData: this._extractWaveform(buffer), + }; + this._clips.push(clip); + this._players.push(this._makePlayer(clip)); + this._cellView.push('overview'); + this._granularTimers.push(null); + this._createCell(clip, this._clips.length - 1); + this._updateUI(); + return id; + } + + _extractWaveform(buffer, resolution = 500) { + const ch = buffer.getChannelData(0); + const step = Math.max(1, Math.floor(ch.length / resolution)); + const data = new Float32Array(resolution); + for (let i = 0; i < resolution; i++) { + let peak = 0; + for (let j = 0; j < step; j++) peak = Math.max(peak, Math.abs(ch[i * step + j] || 0)); + data[i] = peak; + } + return data; + } + + _makePlayer(clip) { + return { + clip, source: null, gainNode: null, filterNode: null, + playing: false, loopStart: 0, loopEnd: clip.duration, + }; + } + + // ── Cell DOM ─────────────────────────────────────────────────────────────── + + _createCell(clip, index) { + const { x, y } = cellPosition(index); + + const cell = document.createElement('div'); + cell.className = 'ac-cell'; + cell.dataset.clipId = clip.id; + cell.dataset.idx = index; + cell.style.cssText = ` + position: absolute; left: ${x}px; top: ${y}px; + width: ${CELL_W}px; height: ${CELL_H}px; + background: #fff; border: 1.5px solid #ddd; border-radius: 10px; + overflow: hidden; cursor: pointer; user-select: none; + transition: border-color 0.15s, box-shadow 0.15s; + box-shadow: 0 2px 8px rgba(0,0,0,0.06); + `; + + // Header + const hdr = document.createElement('div'); + hdr.style.cssText = ` + position: absolute; top: 0; left: 0; right: 0; height: 20px; + display: flex; justify-content: space-between; align-items: center; + padding: 0 8px; font-family: 'JetBrains Mono', monospace; + font-size: 0.58rem; color: #888; background: #fafafa; + border-bottom: 1px solid #eee; + `; + hdr.innerHTML = ` + ${clip.name.length > 20 ? clip.name.slice(0, 20) + '…' : clip.name} + ${this._fmtDur(clip.duration)} + `; + + // Waveform canvases + const overviewC = document.createElement('canvas'); + overviewC.width = CELL_W * 2; overviewC.height = 120; + overviewC.style.cssText = `position:absolute;top:20px;left:0;width:${CELL_W}px;height:60px;`; + + const detailC = document.createElement('canvas'); + detailC.width = CELL_W * 2; detailC.height = 120; + detailC.style.cssText = `position:absolute;top:20px;left:0;width:${CELL_W}px;height:60px;display:none;`; + + // Status bar + const status = document.createElement('div'); + status.style.cssText = ` + position: absolute; bottom: 0; left: 0; right: 0; height: 24px; + display: flex; align-items: center; justify-content: space-between; + padding: 0 8px; font-family: 'JetBrains Mono', monospace; + font-size: 0.56rem; color: #bbb; background: #fafafa; + border-top: 1px solid #eee; + `; + status.innerHTML = ` + idle +
+
+
+ `; + + cell.appendChild(hdr); + cell.appendChild(overviewC); + cell.appendChild(detailC); + cell.appendChild(status); + + // Toggle overview/detail on tap (not when dragging canvas) + let tapStart = 0; + cell.addEventListener('pointerdown', () => { tapStart = Date.now(); }); + cell.addEventListener('pointerup', e => { + if (Date.now() - tapStart < 200) { + const idx = parseInt(cell.dataset.idx); + this._cellView[idx] = this._cellView[idx] === 'overview' ? 'detail' : 'overview'; + this._syncCellView(idx); + } + e.stopPropagation(); + }); + + this._canvas.appendChild(cell); + clip._cell = cell; + clip._overviewC = overviewC; + clip._detailC = detailC; + clip._status = status; + + this._drawOverview(clip); + this._syncCellView(index); + } + + _syncCellView(idx) { + const clip = this._clips[idx]; + if (!clip) return; + const v = this._cellView[idx]; + clip._overviewC.style.display = v === 'overview' ? 'block' : 'none'; + clip._detailC.style.display = v === 'detail' ? 'block' : 'none'; + } + + _drawOverview(clip) { + const c = clip._overviewC; + const ctx = c.getContext('2d'); + const w = c.width, h = c.height; + const d = clip.waveformData; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#f0f4ff'; + ctx.fillRect(0, 0, w, h); + ctx.beginPath(); + for (let i = 0; i < d.length; i++) { + const x = (i / d.length) * w; + const a = d[i] * h * 0.44; + const mid = h / 2; + i === 0 ? ctx.moveTo(x, mid - a) : ctx.lineTo(x, mid - a); + } + for (let i = d.length - 1; i >= 0; i--) { + ctx.lineTo((i / d.length) * w, h / 2 + d[i] * h * 0.44); + } + ctx.closePath(); + ctx.fillStyle = 'rgba(74,144,217,0.25)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(74,144,217,0.7)'; + ctx.lineWidth = 1; + ctx.stroke(); + } + + _drawOverviewWithLoop(clip, loopStart, loopEnd) { + this._drawOverview(clip); + const c = clip._overviewC; + const ctx = c.getContext('2d'); + const w = c.width, h = c.height; + const sx = loopStart * w, ex = loopEnd * w; + ctx.fillStyle = 'rgba(0,122,255,0.12)'; + ctx.fillRect(sx, 0, ex - sx, h); + ctx.strokeStyle = 'rgba(0,122,255,0.6)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(sx, 0); ctx.lineTo(sx, h); + ctx.moveTo(ex, 0); ctx.lineTo(ex, h); + ctx.stroke(); + } + + _drawDetail(clip, loopStart, loopEnd, playhead) { + const c = clip._detailC; + const ctx = c.getContext('2d'); + const w = c.width, h = c.height; + const d = clip.waveformData; + const si = Math.floor(loopStart * d.length); + const ei = Math.ceil(loopEnd * d.length); + const sl = d.slice(si, ei); + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#fff8f0'; + ctx.fillRect(0, 0, w, h); + ctx.beginPath(); + for (let i = 0; i < sl.length; i++) { + const x = (i / sl.length) * w; + const a = sl[i] * h * 0.44; + i === 0 ? ctx.moveTo(x, h / 2 - a) : ctx.lineTo(x, h / 2 - a); + } + for (let i = sl.length - 1; i >= 0; i--) { + ctx.lineTo((i / sl.length) * w, h / 2 + sl[i] * h * 0.44); + } + ctx.closePath(); + ctx.fillStyle = 'rgba(230,140,50,0.25)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(230,140,50,0.8)'; + ctx.lineWidth = 1; + ctx.stroke(); + if (playhead != null) { + ctx.strokeStyle = '#ff3b30'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(playhead * w, 0); + ctx.lineTo(playhead * w, h); + ctx.stroke(); + } + } + + _setCellActive(clip, label, level, color = '#4a90d9') { + const s = clip._status; + s.querySelector('.ac-st-label').textContent = label; + s.querySelector('.ac-st-level').style.width = `${Math.round(level * 100)}%`; + s.querySelector('.ac-st-level').style.background = color; + clip._cell.style.borderColor = level > 0.05 + ? `${color}${Math.round(40 + level * 180).toString(16).padStart(2, '0')}` + : '#ddd'; + clip._cell.style.boxShadow = level > 0.05 + ? `0 0 ${level * 18}px ${color}${Math.round(level * 80).toString(16).padStart(2, '0')}` + : '0 2px 8px rgba(0,0,0,0.06)'; + } + + _setCellIdle(clip) { + this._setCellActive(clip, 'idle', 0); + } + + // ── Audio init ───────────────────────────────────────────────────────────── + + async _initAudio() { + this._audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + this._masterGain = this._audioCtx.createGain(); + this._masterGain.gain.value = 0.75; + this._masterGain.connect(this._audioCtx.destination); + } + + // ── Playback ─────────────────────────────────────────────────────────────── + + async start() { + if (this._running) return; + if (!this._audioCtx) await this._initAudio(); + if (this._audioCtx.state === 'suspended') await this._audioCtx.resume(); + this._running = true; + this._updatePlayBtn(true); + this._startPlayback(); + } + + stop() { + if (!this._running) return; + this._running = false; + this._updatePlayBtn(false); + this._stopAll(); + this._clips.forEach(c => this._setCellIdle(c)); + } + + _stopAll() { + clearInterval(this._loopUpdateTimer); + clearTimeout(this._slicerTimer); + this._loopUpdateTimer = null; + this._slicerTimer = null; + this._granularTimers.forEach((t, i) => { + if (t) clearInterval(t); + this._granularTimers[i] = null; + }); + for (const p of this._players) { + if (p.source) { try { p.source.stop(); } catch (_) {} p.source = null; } + if (p.gainNode) { p.gainNode.disconnect(); p.gainNode = null; } + if (p.filterNode) { p.filterNode.disconnect(); p.filterNode = null; } + p.playing = false; + } + } + + _startPlayback() { + if (this._clips.length === 0) return; + if (this._submode === 'remix' || this._submode === 'drone') { + this._startLooping(); + } else if (this._submode === 'granular') { + this._startGranular(); + } else if (this._submode === 'slicer') { + this._slicerStep = 0; + this._scheduleSlicer(); + } + } + + _updatePlayBtn(playing) { + const btn = this._controlBar.querySelector('#ac-play'); + btn.innerHTML = playing + ? `` + : ``; + } + + // ── Remix / Drone ────────────────────────────────────────────────────────── + + _startLooping() { + for (let i = 0; i < this._clips.length; i++) this._startClipLoop(i); + this._loopUpdateTimer = setInterval(() => this._tickLooping(), 60); + } + + _startClipLoop(i) { + const clip = this._clips[i]; + const player = this._players[i]; + + const filterNode = this._audioCtx.createBiquadFilter(); + filterNode.type = 'lowpass'; + filterNode.frequency.value = 4000; + filterNode.Q.value = 0.7; + + const gainNode = this._audioCtx.createGain(); + gainNode.gain.value = 0.5; + + filterNode.connect(gainNode); + gainNode.connect(this._masterGain); + + const source = this._audioCtx.createBufferSource(); + source.buffer = clip.buffer; + source.loop = true; + source.loopStart = 0; + source.loopEnd = clip.duration; + source.playbackRate.value = this._submode === 'drone' ? 0.05 : 1.0; + source.connect(filterNode); + source.start(); + + player.source = source; + player.gainNode = gainNode; + player.filterNode = filterNode; + player.playing = true; + } + + _tickLooping() { + if (!this._running) return; + const now = this._audioCtx.currentTime; + const isDrone = this._submode === 'drone'; + + for (let i = 0; i < this._clips.length; i++) { + const clip = this._clips[i]; + const player = this._players[i]; + if (!player.playing) continue; + + const vol = this._outputs[i] ?? 0.5; + const pitchNorm = this._outputs[9 + i] ?? 0.5; + const loopNorm = this._outputs[18 + i] ?? 0; + const filtNorm = this._outputs[27 + i] ?? 0.7; + + const speed = isDrone + ? 0.005 + pitchNorm * 0.095 // 0.005×–0.1× for drone + : 0.5 + pitchNorm * 1.5; // 0.5×–2.0× for remix + + // Loop window: treat loopNorm as start of a sliding window + const winSecs = isDrone ? 120 : 15; + const loopStart = loopNorm * Math.max(0, clip.duration - winSecs); + const loopEnd = Math.min(loopStart + winSecs, clip.duration); + const filterHz = 200 * Math.pow(40, filtNorm); // 200 Hz – 8000 Hz log + + player.source.playbackRate.setTargetAtTime(speed, now, 0.08); + player.source.loopStart = loopStart; + player.source.loopEnd = loopEnd; + player.gainNode.gain.setTargetAtTime(isDrone ? vol * 0.7 : vol, now, 0.06); + player.filterNode.frequency.setTargetAtTime(filterHz, now, 0.06); + + // Visuals + const ls = loopStart / clip.duration, le = loopEnd / clip.duration; + this._drawOverviewWithLoop(clip, ls, le); + if (this._cellView[i] === 'detail') this._drawDetail(clip, ls, le, null); + + const color = isDrone ? '#8040c0' : '#4a90d9'; + this._setCellActive(clip, isDrone ? 'drone' : 'remix', vol, color); + } + } + + // ── Granular ─────────────────────────────────────────────────────────────── + + _startGranular() { + for (let i = 0; i < this._clips.length; i++) this._startClipGranular(i); + } + + _startClipGranular(i) { + if (this._granularTimers[i]) clearInterval(this._granularTimers[i]); + this._granularTimers[i] = setInterval(() => { + if (!this._running) { clearInterval(this._granularTimers[i]); return; } + this._spawnGrain(i); + }, 35); + } + + _spawnGrain(i) { + const clip = this._clips[i]; + if (!clip) return; + + const density = this._outputs[i] ?? 0.5; // 0→rare, 1→dense + const pitchNorm = this._outputs[9 + i] ?? 0.5; // 0→0.5×, 1→2× + const posNorm = this._outputs[18 + i] ?? 0.5; // position center + const sizeNorm = this._outputs[27 + i] ?? 0.3; // 0→20ms, 1→400ms + + if (Math.random() > density) return; + + const grainDur = 0.02 + sizeNorm * 0.38; + const pitch = 0.5 + pitchNorm * 1.5; + const scatter = 0.04; + const pos = Math.max(0, Math.min(1, posNorm + (Math.random() - 0.5) * scatter * 2)); + const startOff = pos * Math.max(0, clip.duration - grainDur); + + const gain = this._audioCtx.createGain(); + const t = this._audioCtx.currentTime; + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(0.4 * density, t + grainDur * 0.15); + gain.gain.linearRampToValueAtTime(0.4 * density, t + grainDur * 0.85); + gain.gain.linearRampToValueAtTime(0, t + grainDur); + gain.connect(this._masterGain); + + const src = this._audioCtx.createBufferSource(); + src.buffer = clip.buffer; + src.playbackRate.value = pitch; + src.connect(gain); + src.start(t, startOff, grainDur); + src.onended = () => gain.disconnect(); + + // Visuals: draw grain position + this._drawOverviewWithLoop(clip, Math.max(0, posNorm - 0.03), Math.min(1, posNorm + 0.03)); + this._setCellActive(clip, 'granular', density, '#7040c0'); + } + + // ── Slicer ───────────────────────────────────────────────────────────────── + + _scheduleSlicer() { + if (!this._running) return; + const stepMs = this._beatSync ? (60000 / this._bpm / 2) : (60000 / this._bpm); + this._slicerTimer = setTimeout(() => { + this._fireSlice(); + this._scheduleSlicer(); + }, stepMs); + } + + _fireSlice() { + const step = this._slicerStep % 8; + this._slicerStep++; + + if (this._clips.length === 0) return; + + const clipNorm = this._outputs[step] ?? 0.5; + const clipIdx = Math.min(this._clips.length - 1, Math.floor(clipNorm * this._clips.length)); + const clip = this._clips[clipIdx]; + + const posNorm = this._outputs[9 + step] ?? 0.5; + const speedNorm = this._outputs[18 + step] ?? 0.5; + const sliceDur = Math.min(1.2, (this._beatSync ? 60 / this._bpm / 2 : 60 / this._bpm)); + const speed = 0.5 + speedNorm * 1.5; + const startOff = posNorm * Math.max(0, clip.duration - sliceDur); + + const gain = this._audioCtx.createGain(); + const t = this._audioCtx.currentTime; + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(0.7, t + 0.004); + gain.gain.linearRampToValueAtTime(0.7, t + sliceDur * 0.75); + gain.gain.linearRampToValueAtTime(0, t + sliceDur); + gain.connect(this._masterGain); + + const src = this._audioCtx.createBufferSource(); + src.buffer = clip.buffer; + src.playbackRate.value = speed; + src.connect(gain); + src.start(t, startOff, sliceDur / speed); + src.onended = () => gain.disconnect(); + + // Visuals: flash active cell, idle others + this._clips.forEach((c, i) => { + if (i === clipIdx) { + this._drawOverviewWithLoop(c, posNorm - 0.02, posNorm + 0.02); + this._setCellActive(c, `slice ${step}`, speedNorm, '#ff3b30'); + } else { + this._drawOverview(c); + this._setCellIdle(c); + } + }); + } + + // ── Public API ───────────────────────────────────────────────────────────── + + setOutputs(outputs) { + for (let i = 0; i < Math.min(outputs.length, N_AUDIO_OUTPUTS); i++) { + this._outputs[i] = outputs[i]; + } + } + + setSubmode(mode) { + if (!SUBMODES.includes(mode)) return; + const wasRunning = this._running; + if (wasRunning) this.stop(); + this._submode = mode; + this._syncSubmodeUI(); + if (wasRunning) this.start(); + } + + getOutputCount() { return N_AUDIO_OUTPUTS; } + isRunning() { return this._running; } + + destroy() { + this.stop(); + if (this._audioCtx) { this._audioCtx.close(); this._audioCtx = null; } + this._container.innerHTML = ''; + } + + getState() { + return { submode: this._submode, beatSync: this._beatSync, bpm: this._bpm, panX: this._panX, panY: this._panY, zoom: this._zoom }; + } + + setState(s) { + if (s.submode) { this._submode = s.submode; this._syncSubmodeUI(); } + if (s.beatSync !== undefined) this._beatSync = s.beatSync; + if (s.bpm) this._bpm = s.bpm; + if (s.panX !== undefined) this._panX = s.panX; + if (s.panY !== undefined) this._panY = s.panY; + if (s.zoom) this._zoom = s.zoom; + this._applyTransform(); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + _fmtDur(s) { + return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`; + } + + _updateUI() { + const ct = this._container.querySelector('#ac-clip-ct'); + if (ct) ct.textContent = `${this._clips.length} clip${this._clips.length !== 1 ? 's' : ''}`; + this._dropOverlay.style.opacity = this._clips.length > 0 ? '0' : '1'; + } +}