feat(playground): MIDI CC device presets + bug fixes

Add MIDI CC preset system for bundled device configurations:
- midi-cc-presets.js: listPresets/loadPreset/loadPresetFromFile API
- presets/polybrute.json: Arturia PolyBrute CC map
- Preset selector in quick controls bar and MIDI CC drawer
- File import button for loading JSON presets from disk

Bug fixes in a-app.js:
- Destroy IML instances before resizeMLP() to free WASM memory
- Fix randomiseWeights() call (was drawWeights())
- Fix thumbs-up to store rawParamValues instead of post-pipeline outputs
This commit is contained in:
w1n5t0n 2026-04-02 20:35:38 +01:00
parent aa0ffcfd32
commit 0a112f2d84
5 changed files with 292 additions and 5 deletions

View file

@ -64,6 +64,9 @@
<select class="preset-select" id="midi-cc-output-select" title="MIDI output device"> <select class="preset-select" id="midi-cc-output-select" title="MIDI output device">
<option value="">No device</option> <option value="">No device</option>
</select> </select>
<select class="preset-select" id="midi-cc-quick-preset" title="MIDI CC preset" style="max-width:130px">
<option value="">Manual</option>
</select>
<span class="synth-status" id="midi-cc-status" style="font-size:0.65rem;opacity:0.7"></span> <span class="synth-status" id="midi-cc-status" style="font-size:0.65rem;opacity:0.7"></span>
</div> </div>
@ -269,6 +272,13 @@
<button class="drawer-close" data-drawer="midi-cc">&times;</button> <button class="drawer-close" data-drawer="midi-cc">&times;</button>
</div> </div>
<div class="drawer-body" id="midi-cc-panel"> <div class="drawer-body" id="midi-cc-panel">
<div class="synth-row">
<label>Preset</label>
<select id="midi-cc-preset-select" style="flex:1;min-width:0">
<option value="">Manual</option>
</select>
<button class="action-btn" id="midi-cc-file-import" title="Load preset from JSON file">File</button>
</div>
<div class="synth-row"> <div class="synth-row">
<label>Parameters</label> <label>Parameters</label>
<span id="midi-cc-count">8</span> <span id="midi-cc-count">8</span>
@ -276,8 +286,8 @@
<button class="action-btn" id="midi-cc-remove" title="Remove last CC parameter">&minus;</button> <button class="action-btn" id="midi-cc-remove" title="Remove last CC parameter">&minus;</button>
</div> </div>
<div class="synth-row"> <div class="synth-row">
<button class="action-btn" id="midi-cc-import">Import</button> <button class="action-btn" id="midi-cc-import">Paste JSON</button>
<button class="action-btn" id="midi-cc-export">Export</button> <button class="action-btn" id="midi-cc-export">Copy JSON</button>
</div> </div>
<div id="midi-cc-param-list"></div> <div id="midi-cc-param-list"></div>
</div> </div>

View file

@ -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 { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyCurve, applyGroupOverride } from './synth/param-map.js';
import { MIDIOutput } from './midi/midi-output.js'; import { MIDIOutput } from './midi/midi-output.js';
import { loadCCMap, saveCCMap, createCCParam, exportCCMap, importCCMap, CC_NAMES } from './midi/midi-cc-map.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 { GamepadInput } from './ui/gamepad.js';
import { HandTracker } from './ui/hand-tracker.js'; import { HandTracker } from './ui/hand-tracker.js';
import { createDevPanel } from './ui/dev-panel.js'; import { createDevPanel } from './ui/dev-panel.js';
@ -719,6 +720,10 @@ async function resizeMLP(newOutputCount) {
if (newOutputCount === N_OUTPUTS) return; if (newOutputCount === N_OUTPUTS) return;
N_OUTPUTS = newOutputCount; 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 = await WasmIML.create(N_JOY_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001);
imlJoy.setLogger(msg => console.log('[NISPS:joy]', msg)); 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); 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); rawParamValues = new Array(N_OUTPUTS).fill(0.5);
// Randomize with current spread // Randomize with current spread
imlJoy.drawWeights(spreadLevel); imlJoy.randomiseWeights(spreadLevel);
imlHand.drawWeights(spreadLevel); imlHand.randomiseWeights(spreadLevel);
// Re-run inference // Re-run inference
iml.setInput(0, joyX); iml.setInput(0, joyX);
@ -1922,7 +1927,7 @@ function onThumbsUp() {
pushUndoSnapshot(); pushUndoSnapshot();
const inputs = getCurrentInputs(); const inputs = getCurrentInputs();
const outputs = [...iml.getOutputs()]; const outputs = [...rawParamValues];
iml.addExample(inputs, outputs); iml.addExample(inputs, outputs);
noiseLevel *= rlExplorationDecay; 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 ---- // ---- MIDI CC Controls ----
async function initMIDICCControls() { async function initMIDICCControls() {
const enableBtn = document.getElementById('midi-cc-enable-btn'); const enableBtn = document.getElementById('midi-cc-enable-btn');
@ -2279,6 +2315,8 @@ async function initMIDICCControls() {
const removeBtn = document.getElementById('midi-cc-remove'); const removeBtn = document.getElementById('midi-cc-remove');
const importBtn = document.getElementById('midi-cc-import'); const importBtn = document.getElementById('midi-cc-import');
const exportBtn = document.getElementById('midi-cc-export'); 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; 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 = '<option value="">Manual</option>';
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 = '<option value="">Manual</option>';
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 // Build the per-param editor in the drawer
buildMidiCCParamList(); buildMidiCCParamList();
} }

View file

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

View file

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

View file

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