Skip to content

Instantly share code, notes, and snippets.

@jamesmacaulay
Created May 29, 2026 15:53
Show Gist options
  • Select an option

  • Save jamesmacaulay/1a4012a2fce614ce4575b59b44bb6ccd to your computer and use it in GitHub Desktop.

Select an option

Save jamesmacaulay/1a4012a2fce614ce4575b59b44bb6ccd to your computer and use it in GitHub Desktop.
Deluge Drop Mapper
<!DOCTYPE html>
<!--
dropmap — Neuzeit Drop ⇄ Synthstrom Deluge MIDI-mapping editor.
Single self-contained file: all JS/CSS inline, no build, no network, 100% client-side.
Hostable as a GitHub gist via https://htmlpreview.github.io.
The Deluge song file never leaves the browser; output is a NEW download.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>dropmap — Drop ⇄ Deluge mapping editor</title>
<style>
:root {
--bg:#15161a; --panel:#1e2026; --panel2:#262932; --edge:#33384a;
--txt:#e7e9ee; --dim:#9aa0ad; --accent:#ffb454; --accent2:#5fd0c5;
--rot:#3a3050; --fader:#2a3d4a; --btn:#4a2f33; --warn:#ff6b6b; --ok:#7bd88f;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--txt);
font:14px/1.45 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; }
header { padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--edge); }
header h1 { margin:0; font-size:18px; letter-spacing:.3px; }
header h1 span { color:var(--accent); }
header .sub { color:var(--dim); font-size:12px; margin-top:3px; }
main { padding:18px 20px 60px; max-width:1280px; margin:0 auto; }
.panel { background:var(--panel); border:1px solid var(--edge); border-radius:10px;
padding:14px 16px; margin-bottom:16px; }
.panel > h2 { margin:0 0 10px; font-size:13px; text-transform:uppercase;
letter-spacing:.8px; color:var(--accent2); }
button { font:inherit; cursor:pointer; background:var(--panel2); color:var(--txt);
border:1px solid var(--edge); border-radius:7px; padding:7px 12px; }
button:hover { border-color:var(--accent); }
button.primary { background:var(--accent); color:#222; border-color:var(--accent); font-weight:600; }
button.primary:disabled { opacity:.4; cursor:not-allowed; }
button.ghost { background:transparent; }
select, input[type=text] { font:inherit; background:var(--panel2); color:var(--txt);
border:1px solid var(--edge); border-radius:6px; padding:4px 6px; max-width:100%; }
select:disabled { opacity:.5; }
label.inline { display:inline-flex; align-items:center; gap:6px; margin-right:14px; color:var(--dim); }
.row { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
.hidden { display:none !important; }
.muted { color:var(--dim); }
.pill { display:inline-block; font-size:11px; padding:1px 7px; border-radius:10px;
border:1px solid var(--edge); color:var(--dim); }
.pill.synth { color:#9fd0ff; border-color:#3a4a66; }
.pill.kit { color:#ffd49f; border-color:#665033; }
.pill.row { color:#c9b6ff; border-color:#4a3a66; }
.pill.other { color:var(--dim); }
/* instrument table */
table.inv { width:100%; border-collapse:collapse; font-size:13px; }
table.inv th, table.inv td { text-align:left; padding:5px 8px; border-bottom:1px solid var(--edge); }
table.inv th { color:var(--dim); font-weight:500; font-size:11px; text-transform:uppercase; }
table.inv td.idx { color:var(--dim); }
/* drop surface */
.layerTabs { display:flex; gap:6px; margin-bottom:12px; }
.layerTabs button.active { background:var(--accent); color:#222; border-color:var(--accent); }
.surface { display:grid; grid-template-columns:repeat(8, 1fr); gap:8px; }
.col { background:var(--panel2); border:1px solid var(--edge); border-radius:9px;
padding:8px 7px; display:flex; flex-direction:column; gap:6px; }
.col h3 { margin:0; font-size:11px; color:var(--dim); text-align:center; }
.coltarget { font-size:11px; }
.coltarget select { width:100%; }
.ctrl { border-radius:6px; padding:5px 6px; font-size:11px; }
.ctrl.rot { background:var(--rot); }
.ctrl.fader { background:var(--fader); }
.ctrl.btn { background:var(--btn); }
.ctrl .cname { display:flex; justify-content:space-between; color:var(--dim); font-size:10px; }
.ctrl .cc { opacity:.8; }
.ctrl select { width:100%; margin-top:3px; font-size:11px; padding:2px 4px; }
.ctrl .assigned { color:var(--ok); }
.ctrl .unassigned { color:var(--dim); }
.ctrl.conflict { outline:2px solid var(--warn); }
.warns { margin-top:8px; }
.warns .w { color:var(--warn); font-size:12px; }
.warns .info { color:var(--dim); font-size:12px; }
.footnote { color:var(--dim); font-size:12px; margin-top:8px; }
code { background:#0d0e12; padding:1px 5px; border-radius:4px; color:var(--accent2); }
.tpl-grid { display:grid; grid-template-columns:120px 1fr; gap:6px 10px; align-items:center; max-width:520px; }
.tpl-grid label { color:var(--dim); }
.dl-name { font-family:ui-monospace,monospace; color:var(--accent); }
</style>
</head>
<body>
<header>
<h1><span>dropmap</span> — Neuzeit Drop ⇄ Synthstrom Deluge mapping editor</h1>
<div class="sub">Load a Deluge song, assign Drop controls to instrument params, download a remapped song. Nothing leaves your browser.</div>
</header>
<main>
<section class="panel">
<h2>1 · Load Deluge song</h2>
<div class="row">
<input type="file" id="file" accept=".xml,.XML" />
<span id="loadStatus" class="muted">No file loaded.</span>
</div>
<div class="footnote">Only the Deluge XML is read/written. The Drop's CC layout is fixed (post “DAW Init”) and never edited.</div>
</section>
<div id="app" class="hidden">
<section class="panel">
<h2>2 · Instruments in this song</h2>
<div id="inventory"></div>
</section>
<section class="panel">
<h2>3 · Column template <span class="muted" style="text-transform:none;letter-spacing:0">— define a per-column pattern, then fill mapped columns</span></h2>
<div class="row" style="align-items:flex-start; gap:30px">
<div class="tpl-grid" id="tplGrid"></div>
<div style="flex:1; min-width:240px">
<div class="muted" style="margin-bottom:6px">Map each column (current layer) to an instrument below, set the pattern, then:</div>
<button id="genLayer" class="primary">Generate mappings for mapped columns (layer <span id="genLayerLabel">A</span>)</button>
<div class="footnote">Generation overwrites assignments for the controls it touches; tweak any control by hand afterward. Params invalid for a column's instrument type are skipped (with a warning).</div>
</div>
</div>
</section>
<section class="panel">
<h2>4 · Drop control surface</h2>
<div class="row" style="justify-content:space-between">
<div class="layerTabs" id="layerTabs"></div>
<div class="row">
<label class="inline">Default apply mode
<select id="globalMode">
<option value="replace">replace (overwrite instrument's knobs)</option>
<option value="merge">merge (keep existing, add/update)</option>
</select>
</label>
<button id="clearLayer" class="ghost">Clear this layer</button>
</div>
</div>
<div class="surface" id="surface"></div>
<div class="warns" id="warns"></div>
</section>
<section class="panel">
<h2>5 · Download remapped song</h2>
<div class="row">
<button id="download" class="primary">Download remapped XML</button>
<span class="muted">Suggested filename: <span class="dl-name" id="dlName">song_mapped.XML</span></span>
</div>
<div class="footnote">Output preserves all untouched XML byte-for-byte; only mapped instruments' <code>&lt;midiKnobs&gt;</code> and mapped tracks' clip <code>&lt;muteMidiCommand&gt;</code> change. Re-load it here to verify.</div>
</section>
</div>
</main>
<!-- ============================================================ CORE -->
<script id="dropmap-core">
(function (global) {
'use strict';
// ---------- Drop control layout (fixed, post DAW-Init) -------------------
// Single global CC counter starting at 1, skipping reserved CC numbers,
// walking controls per layer as: rotaries (row-major) -> buttons -> faders.
// This reproduces the authoritative A/B table in reference/drop_daw_init_cc_map.md.
var RESERVED_CC = [0, 6, 32, 38, 97, 98];
var DROP_LAYERS = ['A', 'B']; // DAW-Init populates A and B; CC space is exhausted past B.
function buildDropLayout(layers) {
layers = layers || DROP_LAYERS;
var reserved = {}; RESERVED_CC.forEach(function (n) { reserved[n] = true; });
var controls = [], byName = {}, n = 0;
function next() { do { n++; } while (reserved[n]); return n; }
function add(c) { controls.push(c); byName[c.name] = c; }
layers.forEach(function (layer) {
for (var r = 1; r <= 4; r++) for (var c = 1; c <= 8; c++) {
add({ name: 'ROT ' + layer + '-' + c + '-' + r, family: 'rot',
layer: layer, col: c, row: r, msgType: 'cc', dropCh: 1, number: next() });
}
for (var b = 1; b <= 8; b++) {
add({ name: 'BTN ' + layer + '-' + b, family: 'btn',
layer: layer, col: b, msgType: 'note', dropCh: 2, number: next() });
}
for (var f = 1; f <= 8; f++) {
add({ name: 'FADER ' + layer + '-' + f, family: 'fader',
layer: layer, col: f, msgType: 'cc', dropCh: 1, number: next() });
}
});
return { controls: controls, byName: byName, layers: layers };
}
// ---------- Deluge param vocabulary (param.cpp @ release_1_2_1) ----------
var DELUGE_PARAMS = {
// Synths + kit rows (UNPATCHED_SOUND, patched params allowed)
SOUND: [
'oscAVolume','oscBVolume','volume','noiseVolume',
'oscAPhaseWidth','oscBPhaseWidth','oscAWavetablePosition','oscBWavetablePosition',
'lpfResonance','hpfResonance','pan',
'modulator1Volume','modulator2Volume',
'lpfFrequency','lpfMorph','hpfMorph','hpfFrequency',
'pitch','oscAPitch','oscBPitch','modulator1Pitch','modulator2Pitch',
'lfo2Rate',
'env1Attack','env1Decay','env1Sustain','env1Release',
'env2Attack','env2Decay','env2Sustain','env2Release',
'modulator1Feedback','modulator2Feedback','carrier1Feedback','carrier2Feedback',
'waveFold',
'lfo1Rate','volumePostFX','volumePostReverbSend',
'delayRate','delayFeedback','reverbAmount',
'modFXRate','modFXDepth','arpRate',
'stutterRate','bass','treble','bassFreq','trebleFreq',
'sampleRateReduction','bitcrushAmount',
'modFXOffset','modFXFeedback','compressorShape','compressorThreshold',
'arpGate','ratchetProbability','ratchetAmount','sequenceLength','rhythm','portamento'
],
// Kit masters + audio clips (UNPATCHED_GLOBAL, patched NOT allowed)
GLOBAL: [
'stutterRate','bass','treble','bassFreq','trebleFreq',
'sampleRateReduction','bitcrushAmount',
'modFXOffset','modFXFeedback','compressorShape','compressorThreshold',
'modFXRate','modFXDepth','delayRate','delayFeedback','pan',
'lpfFrequency','lpfResonance','lpfMorph',
'hpfFrequency','hpfMorph','hpfResonance',
'reverbAmount','volume','sidechainCompressorVolume','pitchAdjust'
]
};
// ---------- parsing (text/line based; ported from dropmap_phase1.py) -----
var INSTR_RE = /^(\t*)<(sound|kit)\b/;
var CLIP_RE = /^(\t*)<(instrumentClip|audioClip)\b/;
var PRESET_RE = /presetName="([^"]+)"/;
var ROWNAME_RE = /\bname="([^"]+)"/;
var INSTR_PRESETNAME_RE = /instrumentPresetName="([^"]*)"/;
var KNOB_OPEN_RE = /<midiKnob channel="(\d+)" ccNumber="(\d+)"/;
var PARAM_RE = /controlsParam="([^"]+)"/;
var MUTE_NOTE_RE = /note="(\d+)"/;
var MUTE_CH_RE = /channel="(\d+)"/;
function rstrip(s) { return s.replace(/\s+$/, ''); }
function findClose(lines, start, indent) {
var m = /^\t*<(\w+)/.exec(lines[start]);
var openTag = m ? m[1] : null;
var k = start, opened = false;
while (k < lines.length) {
var s = rstrip(lines[k]);
if (s.slice(-2) === '/>' && k === start) return k + 1; // self-closed
if (s.slice(-1) === '>') { opened = true; k++; break; }
k++;
}
if (!opened) return k;
var close = repeatTab(indent) + '</' + openTag + '>';
while (k < lines.length) { if (lines[k] === close) return k + 1; k++; }
return k;
}
function scanKnobs(lines, start, end) {
var out = [], cur = null;
for (var i = start; i < end; i++) {
var mo = KNOB_OPEN_RE.exec(lines[i]);
if (mo) cur = { ch: parseInt(mo[1], 10), cc: parseInt(mo[2], 10) };
var mp = PARAM_RE.exec(lines[i]);
if (mp && cur) { out.push({ cc: cur.cc, ch: cur.ch, param: mp[1] }); cur = null; }
}
return out;
}
function findMidiKnobsRegion(lines, start, end, indent) {
var open = repeatTab(indent + 1) + '<midiKnobs>';
var close = repeatTab(indent + 1) + '</midiKnobs>';
var s = -1;
for (var i = start; i < end; i++) { if (lines[i] === open) { s = i; break; } }
if (s === -1) return null;
for (var j = s + 1; j < end; j++) { if (lines[j] === close) return { start: s, end: j }; }
return null;
}
function findMuteRegions(lines, start, end, indent, tag) {
var childPrefix = repeatTab(indent + 1) + '<' + tag;
var close = repeatTab(indent + 1) + '</' + tag + '>';
var regions = [];
for (var i = start; i < end; i++) {
var ln = lines[i];
if (ln.indexOf(childPrefix) === 0) {
var noteM = MUTE_NOTE_RE.exec(ln), chM = MUTE_CH_RE.exec(ln);
var rEnd = i;
if (rstrip(ln).slice(-2) === '/>') { rEnd = i; }
else { for (var j = i + 1; j < end; j++) { if (lines[j] === close) { rEnd = j; break; } } }
regions.push({ start: i, end: rEnd,
note: noteM ? parseInt(noteM[1], 10) : null,
channel: chM ? parseInt(chM[1], 10) : null });
}
}
return regions;
}
function openingTagEndLine(lines, start, end) {
for (var i = start; i < end; i++) {
var s = rstrip(lines[i]);
if (s.slice(-1) === '>') return i;
}
return start;
}
function splitLines(text) {
var eol = text.indexOf('\r\n') !== -1 ? '\r\n' : '\n';
return { lines: text.split(/\r?\n/), eol: eol };
}
function parseSong(text) {
var sp = splitLines(text), lines = sp.lines, eol = sp.eol;
var instruments = [];
for (var i = 0; i < lines.length; i++) {
var m = INSTR_RE.exec(lines[i]);
if (!m) continue;
var indent = m[1].length, tag = m[2], name = null, kind = null, j;
for (j = i; j < Math.min(i + 8, lines.length); j++) {
var pm = PRESET_RE.exec(lines[j]);
if (pm) { name = pm[1]; kind = (tag === 'kit') ? 'kit' : 'synth'; break; }
}
if (name === null && tag === 'sound') {
for (j = i; j < Math.min(i + 4, lines.length); j++) {
var rm = ROWNAME_RE.exec(lines[j]);
if (rm) { name = rm[1]; kind = 'row'; break; }
}
}
if (name !== null) {
instruments.push({ kind: kind, name: name, start: i, indent: indent,
end: null, knobScanEnd: null, knobs: [], parentKitIndex: null });
}
}
var k, inst;
for (k = 0; k < instruments.length; k++) {
inst = instruments[k];
inst.end = findClose(lines, inst.start, inst.indent);
}
var parentKitIndex = null;
for (k = 0; k < instruments.length; k++) {
inst = instruments[k];
if (inst.kind === 'kit') parentKitIndex = k;
if (inst.kind === 'row') inst.parentKitIndex = parentKitIndex;
var scanEnd = inst.end;
if (inst.kind === 'kit') {
for (var s = inst.start; s < inst.end; s++) {
if (lines[s].indexOf('<soundSources') !== -1) { scanEnd = s; break; }
}
}
inst.knobScanEnd = scanEnd;
inst.knobs = scanKnobs(lines, inst.start, scanEnd);
var region = findMidiKnobsRegion(lines, inst.start, scanEnd, inst.indent);
inst.knobsBlockStart = region ? region.start : null;
inst.knobsBlockEnd = region ? region.end : null;
inst.knobInsertAnchor = (inst.kind === 'kit') ? scanEnd : (inst.end - 1);
inst.paramSet = (inst.kind === 'kit') ? 'GLOBAL' : 'SOUND';
// Kit-row mutes live in the row's <sound> as <midiMuteCommand> (kit-global, all clips).
inst.muteRegions = (inst.kind === 'row')
? findMuteRegions(lines, inst.start, inst.end, inst.indent, 'midiMuteCommand') : [];
inst.muteInsertAnchor = inst.end - 1; // before the row's closing </sound>
inst.index = k;
}
// session/audio clips (for track-level mute mapping)
var clips = [];
for (i = 0; i < lines.length; i++) {
var cm = CLIP_RE.exec(lines[i]);
if (!cm) continue;
var cIndent = cm[1].length;
var cEnd = findClose(lines, i, cIndent);
var presetName = null;
var otEnd = openingTagEndLine(lines, i, cEnd);
for (j = i; j <= otEnd; j++) {
var pn = INSTR_PRESETNAME_RE.exec(lines[j]);
if (pn) { presetName = pn[1]; break; }
}
clips.push({ type: cm[2], start: i, end: cEnd, indent: cIndent,
presetName: presetName, openTagEnd: otEnd,
muteRegions: findMuteRegions(lines, i, cEnd, cIndent, 'muteMidiCommand') });
}
return { lines: lines, eol: eol, instruments: instruments, clips: clips };
}
// ---------- serialization (match Deluge formatting exactly) --------------
// NOTE: device port is hard-coded to "din" (Drop→Deluge via TRS/DIN MIDI).
// TODO(USB): if USB-MIDI support is added, this becomes "upstreamUSB" (confirm
// the exact token in firmware before changing) — it appears in both blocks below.
function repeatTab(n) { var s = ''; while (n-- > 0) s += '\t'; return s; }
function serializeKnobsBlock(knobs, indent) {
var o = [repeatTab(indent + 1) + '<midiKnobs>'];
knobs.forEach(function (kk) {
o.push(repeatTab(indent + 2) + '<midiKnob channel="0" ccNumber="' + kk.cc + '"');
o.push(repeatTab(indent + 3) + 'relative="0"');
o.push(repeatTab(indent + 3) + 'controlsParam="' + kk.param + '">');
o.push(repeatTab(indent + 3) + '<device port="din" />');
o.push(repeatTab(indent + 2) + '</midiKnob>');
});
o.push(repeatTab(indent + 1) + '</midiKnobs>');
return o;
}
function serializeMuteBlock(note, indent, tag) {
tag = tag || 'muteMidiCommand';
return [
repeatTab(indent + 1) + '<' + tag + ' channel="1" note="' + note + '">',
repeatTab(indent + 2) + '<device port="din" />',
repeatTab(indent + 1) + '</' + tag + '>'
];
}
function arraysEqual(a, b) {
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
// ---------- apply a plan -------------------------------------------------
// plan: array of {
// instrumentIndex, mode:'replace'|'merge',
// knobs:[{cc,param}],
// manageMute:bool, muteNote:int|null // track-level mute (synth/kit master)
// }
function applyPlan(text, plan) {
var parsed = parseSong(text);
var lines = parsed.lines.slice();
var edits = []; // {start, deleteCount, lines}
var clipsByPreset = {};
parsed.clips.forEach(function (c) {
if (c.presetName == null) return;
(clipsByPreset[c.presetName] = clipsByPreset[c.presetName] || []).push(c);
});
plan.forEach(function (item) {
var inst = parsed.instruments[item.instrumentIndex];
if (!inst) return;
var mode = item.mode || 'replace';
// ---- knobs ----
var knobs;
if (mode === 'merge') {
var byCc = {};
var order = [];
function put(kk) { if (!(kk.cc in byCc)) order.push(kk.cc); byCc[kk.cc] = { cc: kk.cc, param: kk.param }; }
inst.knobs.forEach(put);
(item.knobs || []).forEach(put);
knobs = order.map(function (cc) { return byCc[cc]; });
} else {
knobs = (item.knobs || []).map(function (kk) { return { cc: kk.cc, param: kk.param }; });
}
var blockLines = knobs.length ? serializeKnobsBlock(knobs, inst.indent) : null;
if (inst.knobsBlockStart != null) {
var existing = parsed.lines.slice(inst.knobsBlockStart, inst.knobsBlockEnd + 1);
var repl = blockLines || [];
if (!arraysEqual(existing, repl)) {
edits.push({ start: inst.knobsBlockStart, priority: 0,
deleteCount: inst.knobsBlockEnd - inst.knobsBlockStart + 1, lines: repl });
}
} else if (blockLines) {
edits.push({ start: inst.knobInsertAnchor, deleteCount: 0, lines: blockLines, priority: 0 });
}
// ---- mutes ----
if (item.manageMute) {
var note = (item.muteNote == null) ? null : item.muteNote;
if (inst.kind === 'row') {
// kit-row mute = <midiMuteCommand> at end of the row's <sound> (kit-global)
applyMute(edits, note, mode, inst.muteRegions || [], inst.indent, 'midiMuteCommand', inst.muteInsertAnchor);
} else {
// track-level mute = <muteMidiCommand> in every session clip of this track
(clipsByPreset[inst.name] || []).forEach(function (clip) {
applyMute(edits, note, mode, clip.muteRegions, clip.indent, 'muteMidiCommand', clip.openTagEnd + 1);
});
}
}
});
return applyEdits(lines, edits).join(parsed.eol);
}
// Emit edits to set/clear one mute command in a region's existing set.
function applyMute(edits, note, mode, existing, indent, tag, insertAnchor) {
if (note != null) {
if (existing.some(function (r) { return r.note === note; })) return; // already satisfied → untouched
var block = serializeMuteBlock(note, indent, tag);
if (existing.length) {
edits.push({ start: existing[0].start, deleteCount: existing[0].end - existing[0].start + 1, lines: block, priority: 1 });
for (var r = 1; r < existing.length; r++) {
edits.push({ start: existing[r].start, deleteCount: existing[r].end - existing[r].start + 1, lines: [], priority: 1 });
}
} else {
edits.push({ start: insertAnchor, deleteCount: 0, lines: block, priority: 1 });
}
} else if (mode === 'replace') {
existing.forEach(function (reg) {
edits.push({ start: reg.start, deleteCount: reg.end - reg.start + 1, lines: [], priority: 1 });
});
}
}
// Apply edits via a single forward pass (no splice index drift, deterministic ordering).
function applyEdits(lines, edits) {
edits.sort(function (a, b) {
if (a.start !== b.start) return a.start - b.start;
var ai = a.deleteCount === 0 ? 0 : 1, bi = b.deleteCount === 0 ? 0 : 1; // inserts before replacements
if (ai !== bi) return ai - bi;
return (a.priority || 0) - (b.priority || 0);
});
var out = [], ei = 0;
for (var i = 0; i <= lines.length; i++) {
while (ei < edits.length && edits[ei].start === i && edits[ei].deleteCount === 0) {
Array.prototype.push.apply(out, edits[ei].lines); ei++;
}
if (ei < edits.length && edits[ei].start === i && edits[ei].deleteCount > 0) {
Array.prototype.push.apply(out, edits[ei].lines);
i += edits[ei].deleteCount - 1; ei++; continue;
}
if (i < lines.length) out.push(lines[i]);
}
return out;
}
global.DROPMAP = {
RESERVED_CC: RESERVED_CC, DROP_LAYERS: DROP_LAYERS,
buildDropLayout: buildDropLayout, DELUGE_PARAMS: DELUGE_PARAMS,
parseSong: parseSong, applyPlan: applyPlan,
serializeKnobsBlock: serializeKnobsBlock, serializeMuteBlock: serializeMuteBlock,
findClose: findClose
};
})(typeof window !== 'undefined' ? window : globalThis);
</script>
<!-- ============================================================ UI -->
<script id="dropmap-ui">
(function () {
'use strict';
if (typeof document === 'undefined') return; // node test loads core only
var DM = window.DROPMAP;
var $ = function (id) { return document.getElementById(id); };
var el = function (tag, props, kids) {
var n = document.createElement(tag);
if (props) Object.keys(props).forEach(function (k) {
if (k === 'class') n.className = props[k];
else if (k === 'html') n.innerHTML = props[k];
else if (k === 'text') n.textContent = props[k];
else if (k.slice(0, 2) === 'on') n.addEventListener(k.slice(2), props[k]);
else n.setAttribute(k, props[k]);
});
(kids || []).forEach(function (c) { if (c != null) n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
return n;
};
var paramUnion = (function () {
var seen = {}, out = [];
DM.DELUGE_PARAMS.SOUND.concat(DM.DELUGE_PARAMS.GLOBAL).forEach(function (p) {
if (!seen[p]) { seen[p] = 1; out.push(p); }
});
return out;
})();
var state = {
fileName: null, songText: null, parsed: null,
layout: DM.buildDropLayout(),
layer: 'A',
mode: 'replace',
assignments: {}, // controlName -> {instrumentIndex, param}
buttonAssignments: {}, // BTN controlName -> instrumentIndex
initialButtons: {}, // BTN controlName -> instrumentIndex (from loaded file)
preservedKnobs: {}, // instrumentIndex -> [{cc,param}] for CCs outside layout
columnTarget: {}, // "layer-col" -> instrumentIndex
template: { fader: 'volume', rot: ['delayFeedback', 'delayRate', 'reverbAmount', 'hpfFrequency'], includeBtn: true }
};
function instLabel(inst) {
if (inst.kind === 'row') {
var kit = state.parsed.instruments[inst.parentKitIndex];
return (kit ? kit.name : 'kit') + ' / ' + inst.name;
}
return inst.name;
}
function ccToControlName(cc) {
var ctrls = state.layout.controls;
for (var i = 0; i < ctrls.length; i++) if (ctrls[i].msgType === 'cc' && ctrls[i].number === cc) return ctrls[i].name;
return null;
}
function noteToButtonName(note) {
var ctrls = state.layout.controls;
for (var i = 0; i < ctrls.length; i++) if (ctrls[i].family === 'btn' && ctrls[i].number === note) return ctrls[i].name;
return null;
}
// ---------- load ----------
$('file').addEventListener('change', function (e) {
var f = e.target.files[0];
if (!f) return;
var reader = new FileReader();
reader.onload = function () { loadSong(f.name, String(reader.result)); };
reader.readAsText(f);
});
function loadSong(name, text) {
state.fileName = name;
state.songText = text;
state.parsed = DM.parseSong(text);
state.assignments = {};
state.buttonAssignments = {};
state.initialButtons = {};
state.preservedKnobs = {};
state.columnTarget = {};
// pre-fill knob assignments from existing midiKnobs
state.parsed.instruments.forEach(function (inst) {
inst.knobs.forEach(function (k) {
var cn = ccToControlName(k.cc);
if (cn) state.assignments[cn] = { instrumentIndex: inst.index, param: k.param };
else { (state.preservedKnobs[inst.index] = state.preservedKnobs[inst.index] || []).push({ cc: k.cc, param: k.param }); }
});
});
// pre-fill button assignments from existing clip mutes
var byPreset = {};
state.parsed.instruments.forEach(function (inst) { if (inst.kind !== 'row') byPreset[inst.name] = inst.index; });
state.parsed.clips.forEach(function (clip) {
if (clip.presetName == null || !(clip.presetName in byPreset)) return;
clip.muteRegions.forEach(function (reg) {
if (reg.note == null) return;
var bn = noteToButtonName(reg.note);
if (bn) { state.buttonAssignments[bn] = byPreset[clip.presetName]; state.initialButtons[bn] = byPreset[clip.presetName]; }
});
});
// pre-fill kit-row mute buttons from each row's <midiMuteCommand>
state.parsed.instruments.forEach(function (inst) {
if (inst.kind !== 'row') return;
(inst.muteRegions || []).forEach(function (reg) {
if (reg.note == null) return;
var bn = noteToButtonName(reg.note);
if (bn) { state.buttonAssignments[bn] = inst.index; state.initialButtons[bn] = inst.index; }
});
});
deriveColumnTargets();
$('loadStatus').textContent = name + ' — ' + state.parsed.instruments.length + ' instruments, ' + state.parsed.clips.length + ' clips.';
$('loadStatus').className = '';
$('app').classList.remove('hidden');
$('dlName').textContent = suggestedName();
renderAll();
}
// Infer each column's target instrument from the learned midiKnobs: if every
// rotary/fader in a column that has an assignment points to the SAME instrument,
// pre-select it as the column target; if a column mixes instruments, leave blank.
function deriveColumnTargets() {
state.columnTarget = {};
state.layout.layers.forEach(function (L) {
for (var col = 1; col <= 8; col++) {
var idxs = {};
for (var r = 1; r <= 4; r++) {
var a = state.assignments['ROT ' + L + '-' + col + '-' + r];
if (a && a.instrumentIndex != null) idxs[a.instrumentIndex] = true;
}
var fa = state.assignments['FADER ' + L + '-' + col];
if (fa && fa.instrumentIndex != null) idxs[fa.instrumentIndex] = true;
var keys = Object.keys(idxs);
if (keys.length === 1) state.columnTarget[L + '-' + col] = parseInt(keys[0], 10);
}
});
}
function suggestedName() {
var n = state.fileName || 'song.XML';
var dot = n.lastIndexOf('.');
var base = dot > 0 ? n.slice(0, dot) : n;
var ext = dot > 0 ? n.slice(dot + 1) : 'XML';
return base + '_mapped.' + ext;
}
// ---------- render ----------
function renderAll() { renderInventory(); renderTemplate(); renderLayerTabs(); renderSurface(); renderWarnings(); }
function renderInventory() {
var t = el('table', { class: 'inv' });
var head = el('tr', {}, [el('th', { text: '#' }), el('th', { text: 'type' }), el('th', { text: 'instrument' }),
el('th', { text: 'param set' }), el('th', { text: 'existing knobs' }), el('th', { text: 'clips' })]);
t.appendChild(head);
var clipCount = {};
state.parsed.clips.forEach(function (c) { if (c.presetName) clipCount[c.presetName] = (clipCount[c.presetName] || 0) + 1; });
state.parsed.instruments.forEach(function (inst) {
var pillCls = inst.kind === 'synth' ? 'synth' : inst.kind === 'kit' ? 'kit' : 'row';
var pres = state.preservedKnobs[inst.index];
var knobTxt = inst.knobs.length ? (inst.knobs.length + ' knob(s)') : '—';
if (pres && pres.length) knobTxt += ' (+' + pres.length + ' off-layout)';
t.appendChild(el('tr', {}, [
el('td', { class: 'idx', text: String(inst.index) }),
el('td', {}, [el('span', { class: 'pill ' + pillCls, text: inst.kind })]),
el('td', { text: instLabel(inst) }),
el('td', { class: 'muted', text: inst.paramSet }),
el('td', { class: 'muted', text: knobTxt }),
el('td', { class: 'muted', text: inst.kind === 'row' ? 'n/a' : String(clipCount[inst.name] || 0) })
]));
});
var box = $('inventory'); box.innerHTML = ''; box.appendChild(t);
}
function instrumentOptions(selectedIdx, includeNone) {
var opts = [];
if (includeNone) opts.push(el('option', { value: '', text: '— none —' }));
state.parsed.instruments.forEach(function (inst) {
var o = el('option', { value: String(inst.index), text: instLabel(inst) + ' [' + inst.kind + ']' });
if (String(inst.index) === String(selectedIdx)) o.selected = true;
opts.push(o);
});
return opts;
}
function paramOptions(list, selected) {
var opts = [el('option', { value: '', text: '— param —' })];
list.slice().sort().forEach(function (p) { // alphabetical for easy scanning
var o = el('option', { value: p, text: p });
if (p === selected) o.selected = true;
opts.push(o);
});
return opts;
}
function renderTemplate() {
var g = $('tplGrid'); g.innerHTML = '';
function paramRow(labelTxt, value, onChange) {
g.appendChild(el('label', { text: labelTxt }));
var sel = el('select', { onchange: function () { onChange(sel.value); } }, paramOptions(paramUnion, value));
g.appendChild(sel);
}
for (var r = 1; r <= 4; r++) (function (r) {
paramRow('ROT row ' + r, state.template.rot[r - 1], function (v) { state.template.rot[r - 1] = v; });
})(r);
paramRow('FADER', state.template.fader, function (v) { state.template.fader = v; });
g.appendChild(el('label', { text: 'Mute button' }));
var chkWrap = el('label', { class: 'inline' }, []);
var chk = el('input', { type: 'checkbox' });
chk.checked = state.template.includeBtn;
chk.addEventListener('change', function () { state.template.includeBtn = chk.checked; });
chkWrap.appendChild(chk);
chkWrap.appendChild(document.createTextNode(' BTN → mute the column’s instrument'));
g.appendChild(chkWrap);
}
function renderLayerTabs() {
var box = $('layerTabs'); box.innerHTML = '';
state.layout.layers.forEach(function (L) {
box.appendChild(el('button', {
class: L === state.layer ? 'active' : '',
text: 'Layer ' + L,
onclick: function () { state.layer = L; $('genLayerLabel').textContent = L; renderLayerTabs(); renderSurface(); }
}));
});
$('genLayerLabel').textContent = state.layer;
}
function conflictKeys() {
// map "instIdx::param" -> [controlNames]; >1 => conflict
var map = {};
Object.keys(state.assignments).forEach(function (cn) {
var a = state.assignments[cn];
if (a.instrumentIndex == null || !a.param) return;
var key = a.instrumentIndex + '::' + a.param;
(map[key] = map[key] || []).push(cn);
});
return map;
}
function renderSurface() {
var surface = $('surface'); surface.innerHTML = '';
var L = state.layer;
var conflicts = conflictKeys();
for (var col = 1; col <= 8; col++) (function (col) {
var colKey = L + '-' + col;
var colDiv = el('div', { class: 'col' });
colDiv.appendChild(el('h3', { text: 'Col ' + col }));
// column target selector
var tgtSel = el('select', { onchange: function () {
state.columnTarget[colKey] = tgtSel.value === '' ? undefined : parseInt(tgtSel.value, 10);
} }, instrumentOptions(state.columnTarget[colKey], true));
var tgtWrap = el('div', { class: 'coltarget' }, [el('span', { class: 'muted', text: 'target' }), tgtSel]);
colDiv.appendChild(tgtWrap);
// rotaries rows 1..4
for (var r = 1; r <= 4; r++) {
colDiv.appendChild(controlCell('ROT ' + L + '-' + col + '-' + r, 'rot', conflicts));
}
// fader
colDiv.appendChild(controlCell('FADER ' + L + '-' + col, 'fader', conflicts));
// mute button
colDiv.appendChild(buttonCell('BTN ' + L + '-' + col));
surface.appendChild(colDiv);
})(col);
}
function controlCell(controlName, fam, conflicts) {
var ctrl = state.layout.byName[controlName];
var a = state.assignments[controlName] || {};
var cell = el('div', { class: 'ctrl ' + fam });
if (a.instrumentIndex != null && a.param) {
var key = a.instrumentIndex + '::' + a.param;
if (conflicts[key] && conflicts[key].length > 1) cell.className += ' conflict';
}
cell.appendChild(el('div', { class: 'cname' }, [
el('span', { text: controlName.replace('ROT ', '').replace('FADER ', 'F') }),
el('span', { class: 'cc', text: 'CC' + ctrl.number })
]));
var instSel = el('select', {}, instrumentOptions(a.instrumentIndex, true));
var paramSel = el('select', {});
function refreshParam() {
var idx = instSel.value === '' ? null : parseInt(instSel.value, 10);
paramSel.innerHTML = '';
if (idx == null) { paramSel.disabled = true; paramSel.appendChild(el('option', { value: '', text: '—' })); return; }
paramSel.disabled = false;
var inst = state.parsed.instruments[idx];
paramOptions(DM.DELUGE_PARAMS[inst.paramSet], (state.assignments[controlName] || {}).param).forEach(function (o) { paramSel.appendChild(o); });
}
instSel.addEventListener('change', function () {
var idx = instSel.value === '' ? null : parseInt(instSel.value, 10);
if (idx == null) { delete state.assignments[controlName]; refreshParam(); renderWarnings(); return; }
var cur = state.assignments[controlName] || {};
cur.instrumentIndex = idx;
// drop param if invalid for new instrument
var inst = state.parsed.instruments[idx];
if (cur.param && DM.DELUGE_PARAMS[inst.paramSet].indexOf(cur.param) === -1) cur.param = '';
state.assignments[controlName] = cur;
refreshParam(); renderWarnings();
});
paramSel.addEventListener('change', function () {
var cur = state.assignments[controlName];
if (!cur) return;
if (paramSel.value === '') { delete state.assignments[controlName]; }
else cur.param = paramSel.value;
renderWarnings(); renderSurfaceConflicts();
});
refreshParam();
cell.appendChild(instSel);
cell.appendChild(paramSel);
return cell;
}
function buttonCell(controlName) {
var ctrl = state.layout.byName[controlName];
var cell = el('div', { class: 'ctrl btn' });
cell.appendChild(el('div', { class: 'cname' }, [
el('span', { text: controlName.replace('BTN ', 'MUTE ') }),
el('span', { class: 'cc', text: 'Note' + ctrl.number })
]));
var sel = el('select', {}, instrumentOptions(state.buttonAssignments[controlName], true));
sel.addEventListener('change', function () {
if (sel.value === '') delete state.buttonAssignments[controlName];
else state.buttonAssignments[controlName] = parseInt(sel.value, 10);
renderWarnings();
});
cell.appendChild(sel);
return cell;
}
function renderSurfaceConflicts() {
// light refresh of conflict outlines without rebuilding selects
var conflicts = conflictKeys();
// simplest: re-render surface
renderSurface();
}
// ---------- column template generation ----------
$('genLayer').addEventListener('click', function () {
var L = state.layer, applied = 0, skipped = [];
for (var col = 1; col <= 8; col++) {
var idx = state.columnTarget[L + '-' + col];
if (idx == null) continue;
var inst = state.parsed.instruments[idx];
var validParams = DM.DELUGE_PARAMS[inst.paramSet];
// rotaries
for (var r = 1; r <= 4; r++) {
var p = state.template.rot[r - 1];
var cn = 'ROT ' + L + '-' + col + '-' + r;
if (!p) { delete state.assignments[cn]; continue; }
if (validParams.indexOf(p) === -1) { skipped.push(cn + ' (' + p + ' invalid for ' + inst.paramSet + ')'); continue; }
state.assignments[cn] = { instrumentIndex: idx, param: p }; applied++;
}
// fader
var fcn = 'FADER ' + L + '-' + col;
if (state.template.fader) {
if (validParams.indexOf(state.template.fader) === -1) skipped.push(fcn + ' (' + state.template.fader + ' invalid for ' + inst.paramSet + ')');
else { state.assignments[fcn] = { instrumentIndex: idx, param: state.template.fader }; applied++; }
} else { delete state.assignments[fcn]; }
// button (works for synths, kit masters, and kit rows)
var bcn = 'BTN ' + L + '-' + col;
if (state.template.includeBtn) { state.buttonAssignments[bcn] = idx; applied++; }
}
renderSurface(); renderWarnings();
if (skipped.length) alert('Generated ' + applied + ' assignments.\nSkipped:\n' + skipped.join('\n'));
});
$('globalMode').addEventListener('change', function () { state.mode = this.value; });
$('clearLayer').addEventListener('click', function () {
var L = state.layer;
state.layout.controls.forEach(function (c) {
if (c.layer !== L) return;
delete state.assignments[c.name];
delete state.buttonAssignments[c.name];
});
for (var col = 1; col <= 8; col++) delete state.columnTarget[L + '-' + col];
renderSurface(); renderWarnings();
});
// ---------- warnings ----------
function renderWarnings() {
var box = $('warns'); box.innerHTML = '';
var conflicts = conflictKeys();
var msgs = [];
Object.keys(conflicts).forEach(function (key) {
if (conflicts[key].length > 1) {
var parts = key.split('::');
var inst = state.parsed.instruments[parseInt(parts[0], 10)];
msgs.push('Same target ' + instLabel(inst) + ' · ' + parts[1] + ' used by: ' + conflicts[key].join(', '));
}
});
msgs.forEach(function (m) { box.appendChild(el('div', { class: 'w', text: '⚠ ' + m })); });
if (!msgs.length) box.appendChild(el('div', { class: 'info', text: 'No conflicts.' }));
}
// ---------- build plan + download ----------
function buildPlan() {
var plan = {};
function item(idx) {
if (!plan[idx]) plan[idx] = { instrumentIndex: idx, mode: state.mode, knobs: [], manageMute: false, muteNote: null };
return plan[idx];
}
// preserved off-layout knobs first (so replace mode keeps them)
Object.keys(state.preservedKnobs).forEach(function (idx) {
state.preservedKnobs[idx].forEach(function (k) { item(parseInt(idx, 10)).knobs.push({ cc: k.cc, param: k.param }); });
});
// knob assignments
Object.keys(state.assignments).forEach(function (cn) {
var a = state.assignments[cn];
if (a.instrumentIndex == null || !a.param) return;
var ctrl = state.layout.byName[cn];
if (!ctrl || ctrl.msgType !== 'cc') return;
item(a.instrumentIndex).knobs.push({ cc: ctrl.number, param: a.param });
});
// button mutes
Object.keys(state.buttonAssignments).forEach(function (cn) {
var idx = state.buttonAssignments[cn];
if (idx == null) return;
var ctrl = state.layout.byName[cn];
if (!ctrl || ctrl.family !== 'btn') return;
var it = item(idx); it.manageMute = true; it.muteNote = ctrl.number;
});
// tracks whose button was removed (initial had one, now none) -> manage to clear (replace mode)
Object.keys(state.initialButtons).forEach(function (cn) {
if (!(cn in state.buttonAssignments)) {
var idx = state.initialButtons[cn];
var it = item(idx); it.manageMute = true; /* muteNote stays null unless another button set it */
}
});
return Object.keys(plan).map(function (k) { return plan[k]; });
}
$('download').addEventListener('click', function () {
if (!state.songText) return;
var out = DM.applyPlan(state.songText, buildPlan());
var blob = new Blob([out], { type: 'application/xml' });
var url = URL.createObjectURL(blob);
var a = el('a', { href: url, download: suggestedName() });
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
});
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment