diff --git a/playground/a-immersive.html b/playground/a-immersive.html index 941e9df..c4ee884 100644 --- a/playground/a-immersive.html +++ b/playground/a-immersive.html @@ -64,6 +64,9 @@ + @@ -269,6 +272,13 @@
+
+ + + +
8 @@ -276,8 +286,8 @@
- - + +
diff --git a/playground/js/a-app.js b/playground/js/a-app.js index d5a1e1c..d820aa9 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -9,6 +9,7 @@ import { MIDIInput } from './synth/midi-input.js'; import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyCurve, applyGroupOverride } from './synth/param-map.js'; import { MIDIOutput } from './midi/midi-output.js'; import { loadCCMap, saveCCMap, createCCParam, exportCCMap, importCCMap, CC_NAMES } from './midi/midi-cc-map.js'; +import { listPresets as listMidiPresets, loadPreset as loadMidiPreset, loadPresetFromFile as loadMidiPresetFromFile } from './midi/midi-cc-presets.js'; import { GamepadInput } from './ui/gamepad.js'; import { HandTracker } from './ui/hand-tracker.js'; import { createDevPanel } from './ui/dev-panel.js'; @@ -719,6 +720,10 @@ async function resizeMLP(newOutputCount) { if (newOutputCount === N_OUTPUTS) return; N_OUTPUTS = newOutputCount; + // Destroy old IML instances (free WASM memory) + if (imlJoy) imlJoy.destroy(); + if (imlHand) imlHand.destroy(); + imlJoy = await WasmIML.create(N_JOY_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001); imlJoy.setLogger(msg => console.log('[NISPS:joy]', msg)); imlHand = await WasmIML.create(N_HAND_INPUTS, N_OUTPUTS, [48, 48, 64], 1000, 1.0, 0.00001); @@ -729,8 +734,8 @@ async function resizeMLP(newOutputCount) { rawParamValues = new Array(N_OUTPUTS).fill(0.5); // Randomize with current spread - imlJoy.drawWeights(spreadLevel); - imlHand.drawWeights(spreadLevel); + imlJoy.randomiseWeights(spreadLevel); + imlHand.randomiseWeights(spreadLevel); // Re-run inference iml.setInput(0, joyX); @@ -1922,7 +1927,7 @@ function onThumbsUp() { pushUndoSnapshot(); const inputs = getCurrentInputs(); - const outputs = [...iml.getOutputs()]; + const outputs = [...rawParamValues]; iml.addExample(inputs, outputs); noiseLevel *= rlExplorationDecay; @@ -2269,6 +2274,37 @@ async function initMIDIControls() { }); } +// ---- MIDI CC Preset Application ---- +async function applyMidiCCPreset(preset) { + // Replace the CC map with preset params + midiCCMap.length = 0; + midiCCMap.push(...preset.params.map(p => ({ ...p }))); + + // Rebuild overrides + midiCCOverrides.length = 0; + midiCCOverrides.push(...preset.params.map(p => ({ + min: p.min ?? 0, max: p.max ?? 1, curve: p.curve ?? 0.5, + frozen: p.muted ?? false, fixedValue: p.fixedValue ?? 0.5, + }))); + + _generateCCColors(midiCCMap.length); + saveCCMap(midiCCMap); + + // Resize MLP if in midi-cc mode + if (outputMode === 'midi-cc') { + await resizeMLP(midiCCMap.length); + buildHeatmap(); + updateHeatmap(iml.getOutputs()); + } + + // Update UI + const countEl = document.getElementById('midi-cc-count'); + if (countEl) countEl.textContent = midiCCMap.length; + buildMidiCCParamList(); + + console.log(`[NISPS] Applied MIDI CC preset: ${preset.name} (${preset.params.length} params)`); +} + // ---- MIDI CC Controls ---- async function initMIDICCControls() { const enableBtn = document.getElementById('midi-cc-enable-btn'); @@ -2279,6 +2315,8 @@ async function initMIDICCControls() { const removeBtn = document.getElementById('midi-cc-remove'); const importBtn = document.getElementById('midi-cc-import'); const exportBtn = document.getElementById('midi-cc-export'); + const presetSelect = document.getElementById('midi-cc-preset-select'); + const fileImportBtn = document.getElementById('midi-cc-file-import'); if (!enableBtn || !outputSelect) return; @@ -2395,6 +2433,85 @@ async function initMIDICCControls() { }); } + // Quick preset selector (in quick controls bar) + const quickPresetSelect = document.getElementById('midi-cc-quick-preset'); + if (quickPresetSelect) { + quickPresetSelect.innerHTML = ''; + for (const p of listMidiPresets()) { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.name; + quickPresetSelect.appendChild(opt); + } + quickPresetSelect.addEventListener('change', async () => { + const id = quickPresetSelect.value; + if (!id) return; + try { + const preset = await loadMidiPreset(id); + await applyMidiCCPreset(preset); + updateCountDisplay(); + if (statusEl) statusEl.textContent = `Loaded: ${preset.name}`; + // Sync drawer preset selector + if (presetSelect) presetSelect.value = id; + } catch (err) { + console.error('[MIDI CC] Failed to load preset:', err); + } + }); + } + + // Preset selector (in drawer) + if (presetSelect) { + // Populate with built-in presets + presetSelect.innerHTML = ''; + for (const p of listMidiPresets()) { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.name; + presetSelect.appendChild(opt); + } + presetSelect.addEventListener('change', async () => { + const id = presetSelect.value; + if (!id) return; // "Manual" selected — keep current config + try { + const preset = await loadMidiPreset(id); + await applyMidiCCPreset(preset); + updateCountDisplay(); + if (statusEl) statusEl.textContent = `Loaded: ${preset.name}`; + // Sync quick preset selector + if (quickPresetSelect) quickPresetSelect.value = id; + } catch (err) { + console.error('[MIDI CC] Failed to load preset:', err); + if (statusEl) statusEl.textContent = `Error: ${err.message}`; + } + }); + } + + // File import (JSON file from disk) + if (fileImportBtn) { + // Create hidden file input + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.json'; + fileInput.style.display = 'none'; + document.body.appendChild(fileInput); + + fileImportBtn.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', async () => { + const file = fileInput.files?.[0]; + if (!file) return; + const preset = await loadMidiPresetFromFile(file); + if (!preset) { + alert('Invalid MIDI CC preset file'); + return; + } + await applyMidiCCPreset(preset); + updateCountDisplay(); + if (statusEl) statusEl.textContent = `Loaded: ${preset.name || file.name}`; + // Reset file input so the same file can be re-selected + fileInput.value = ''; + }); + } + // Build the per-param editor in the drawer buildMidiCCParamList(); } diff --git a/playground/js/midi/midi-cc-presets.js b/playground/js/midi/midi-cc-presets.js new file mode 100644 index 0000000..5fee26f --- /dev/null +++ b/playground/js/midi/midi-cc-presets.js @@ -0,0 +1,60 @@ +// MIDI CC Presets — bundled device configurations +// Each preset is a JSON file with { name, description, channel, params[] } + +const PRESET_URLS = { + 'polybrute': './js/midi/presets/polybrute.json', +}; + +// In-memory cache +const _cache = new Map(); + +/** + * Get list of available preset IDs and names. + * @returns {Array<{id: string, name: string}>} + */ +export function listPresets() { + return [ + { id: 'polybrute', name: 'Arturia PolyBrute' }, + ]; +} + +/** + * Load a preset by ID. Returns the parsed preset object. + * @param {string} id — preset identifier + * @returns {Promise<{name: string, description: string, channel: number, params: Array}>} + */ +export async function loadPreset(id) { + if (_cache.has(id)) return _cache.get(id); + + const url = PRESET_URLS[id]; + if (!url) throw new Error(`Unknown MIDI CC preset: ${id}`); + + const resp = await fetch(url); + if (!resp.ok) throw new Error(`Failed to load preset ${id}: ${resp.status}`); + + const preset = await resp.json(); + _cache.set(id, preset); + return preset; +} + +/** + * Load a preset from a user-provided JSON file (File object). + * @param {File} file + * @returns {Promise<{name: string, description: string, channel: number, params: Array}|null>} + */ +export async function loadPresetFromFile(file) { + try { + const text = await file.text(); + const preset = JSON.parse(text); + if (!preset.params || !Array.isArray(preset.params) || preset.params.length === 0) { + return null; + } + // Validate param shape + for (const p of preset.params) { + if (typeof p.cc !== 'number' || typeof p.channel !== 'number') return null; + } + return preset; + } catch (e) { + return null; + } +} diff --git a/playground/js/midi/presets/polybrute.json b/playground/js/midi/presets/polybrute.json new file mode 100644 index 0000000..dc48c62 --- /dev/null +++ b/playground/js/midi/presets/polybrute.json @@ -0,0 +1,68 @@ +{ + "name": "Arturia PolyBrute", + "description": "Full CC map for PolyBrute analog synthesizer (firmware 3.0+)", + "channel": 1, + "params": [ + { "name": "Ladder Cutoff", "cc": 25, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Steiner Cutoff", "cc": 23, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Master Cutoff", "cc": 27, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Ladder Resonance", "cc": 87, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Steiner Resonance", "cc": 83, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Ladder Disto", "cc": 85, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Steiner Brute", "cc": 82, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Ladder Level", "cc": 8, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Steiner Level", "cc": 7, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO 1 Tune", "cc": 66, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO 2 Tune", "cc": 72, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO 1 Metalizer", "cc": 70, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO 1 Saw/Tri", "cc": 17, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO 1 Saw/Sq", "cc": 12, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO 1 PW", "cc": 69, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO 2 Sub Mix", "cc": 14, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO 2 Saw/Tri", "cc": 15, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO 2 Saw/Sq", "cc": 16, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO 2 PW", "cc": 75, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO 2 FM 2>1", "cc": 77, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Mixer VCO 1", "cc": 18, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Mixer VCO 2", "cc": 19, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Mixer Noise", "cc": 21, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Noise Color", "cc": 22, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCO2>VCF1", "cc": 79, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Noise>VCF2", "cc": 80, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCF Env Amt Lad", "cc": 26, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCF Env Amt Stein", "cc": 24, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Lad Series/Para", "cc": 86, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Stein LP>HP>BP", "cc": 81, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Key Track", "cc": 71, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCF Env Attack", "cc": 102, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCF Env Decay", "cc": 103, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCF Env Sustain", "cc": 28, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCF Env Release", "cc": 104, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCA Env Attack", "cc": 105, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCA Env Decay", "cc": 106, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCA Env Sustain", "cc": 29, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "VCA Env Release", "cc": 107, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Mod Env Delay", "cc": 108, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Mod Env Attack", "cc": 109, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Mod Env Decay", "cc": 110, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Mod Env Sustain", "cc": 30, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Mod Env Release", "cc": 111, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "LFO 1 Phase", "cc": 90, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "LFO 1 Rate", "cc": 91, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "LFO 2 Fade In", "cc": 92, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "LFO 2 Rate", "cc": 93, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "LFO 3 Curve", "cc": 67, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "LFO 3 Symmetry", "cc": 68, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "LFO 3 Rate", "cc": 73, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Mod Intensity", "cc": 13, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Delay Level", "cc": 31, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Delay Time", "cc": 112, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Delay Regen", "cc": 113, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Reverb Level", "cc": 2, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Reverb Time", "cc": 78, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Reverb Damping", "cc": 76, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Stereo", "cc": 10, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Mod Wheel", "cc": 1, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }, + { "name": "Glide", "cc": 5, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 } + ] +} diff --git a/playground/test-midi-receiver.py b/playground/test-midi-receiver.py new file mode 100644 index 0000000..aedb1ee --- /dev/null +++ b/playground/test-midi-receiver.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""MIDI CC receiver — listens on all ALSA ports and prints CC messages.""" +import mido +import sys + +def main(): + ports = mido.get_input_names() + print(f"Available MIDI inputs: {ports}") + + if not ports: + print("No MIDI input ports found!") + sys.exit(1) + + # Open the Midi Through port (Chrome sends to this) + port_name = ports[0] + for p in ports: + if 'Through' in p: + port_name = p + break + + print(f"Listening on: {port_name}") + print("Waiting for MIDI CC messages...\n") + + with mido.open_input(port_name) as inport: + for msg in inport: + if msg.type == 'control_change': + print(f" CC #{msg.control:3d} val={msg.value:3d} ch={msg.channel + 1:2d}") + else: + print(f" {msg}") + +if __name__ == '__main__': + main()