Created
May 29, 2026 15:53
-
-
Save jamesmacaulay/1a4012a2fce614ce4575b59b44bb6ccd to your computer and use it in GitHub Desktop.
Deluge Drop Mapper
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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><midiKnobs></code> and mapped tracks' clip <code><muteMidiCommand></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