@@ -1146,12 +1499,175 @@
404 Busdriver
if (def) def.p.forEach((p, i) => { if (i < 6) out[i] = ccFromDefault(p); });
return out;
}
+/* inverse of dispFromCc - lets the Recipe editor accept the same units a
+ recipe card prints (dB, ms, %, note names, ...) instead of raw 0-127,
+ which is the entire point of typing a card in directly. Mirrors
+ dispFromCc's branches; returns null for anything unparseable so the
+ caller can leave the field alone rather than write garbage. */
+function ccFromDisp(p, v, syncOn) {
+ switch (p.t) {
+ case "b": return /^(on|1|true|yes)$/i.test(String(v).trim()) ? 127 : 0;
+ case "e": {
+ const s = String(v).trim().toLowerCase();
+ const i = p.v.findIndex((o) => o.toLowerCase() === s);
+ if (i >= 0) return ccFromEnum(i, p.v.length);
+ const n = Number(v);
+ return Number.isFinite(n) ? ccFromEnum(clamp(Math.round(n), 0, p.v.length - 1), p.v.length) : null;
+ }
+ case "bal": {
+ /* accepts the app's own "50:50" display format (right side is what
+ matters) as well as a recipe card's likely "80/20" and a bare
+ number, so whatever's printed on a card round-trips */
+ const s = String(v).trim();
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/);
+ const n = m ? Number(m[2]) : Number(s);
+ return Number.isFinite(n) ? clamp(Math.round(n * 127 / 100), 0, 127) : null;
+ }
+ case "pan": {
+ /* accepts the app's own "L12"/"R12"/"CTR" display format as well as
+ a bare signed number */
+ const s = String(v).trim().toUpperCase();
+ let n;
+ if (s === "CTR" || s === "C" || s === "0") n = 0;
+ else if (s.startsWith("L")) n = -Number(s.slice(1));
+ else if (s.startsWith("R")) n = Number(s.slice(1));
+ else n = Number(s);
+ return Number.isFinite(n) ? clamp(Math.round((n + 50) * 127 / 100), 0, 127) : null;
+ }
+ case "nt": { const n = Number(v); return Number.isFinite(n) ? clamp(Math.round(n), 0, 127) : null; }
+ case "c": default: {
+ if (p.sv && syncOn) {
+ if (Array.isArray(p.sv)) {
+ const s = String(v).trim().toLowerCase();
+ const i = p.sv.findIndex((o) => o.toLowerCase() === s);
+ if (i >= 0) return ccFromEnum(i, p.sv.length);
+ } else {
+ /* continuous {hi,lo,u} bar-multiplier range (Phaser/Flanger/Wah/
+ Tremolo RATE when synced) - inverse of dispFromCc's own v =
+ hi + (lo-hi)*cc/127 */
+ const n = Number(v);
+ if (Number.isFinite(n)) return clamp(Math.round((n - p.sv.hi) / (p.sv.lo - p.sv.hi) * 127), 0, 127);
+ }
+ }
+ const n = Number(v);
+ return Number.isFinite(n) ? clamp(Math.round((n - p.a) / (p.b - p.a) * 127), 0, 127) : null;
+ }
+ }
+}
function syncOnFor(bus, def) {
/* is the SYNC bool of this effect currently ON? */
if (!def) return false;
const i = def.p.findIndex((p) => p.n === "SYNC" && p.t === "b");
return i >= 0 && state.buses[bus].cc[i] >= 64;
}
+/* same question, but for an offline cc array (the Recipe editor's draft
+ isn't in state.buses yet) */
+function syncOnForCc(def, cc) {
+ if (!def) return false;
+ const i = def.p.findIndex((p) => p.n === "SYNC" && p.t === "b");
+ return i >= 0 && cc[i] >= 64;
+}
+
+/* factory-shipped recipes: SP-404 "FX Recipe" cards from
+ electronoir.gumroad.com (@noirtdc), transcribed straight from their own
+ printed values. Each bus spec lists only the fields the card actually
+ gives; everything else keeps that effect's own real default (via
+ defaultCcs), never a generic 64. Values go through ccFromDisp so they
+ land on exactly what the card intends (dB, Hz, note values, "L10"/"R10",
+ ratio splits, ...) instead of being hand-rounded here. A few cards give
+ a *range* for a field ("10 to 20") rather than one number - those are
+ meant as a live-tweak/automation range, not a fixed setting, so the
+ midpoint is used as a starting value; still fully editable afterward
+ like any recipe. The blank INPUT bus at the end of every chain is left
+ alone since none of these use it. */
+function buildFactoryBus(table, fxName, vals) {
+ const num = BUS_TABLES[table].indexOf(fxName);
+ const def = fxDefFor(table, num);
+ const cc = defaultCcs(def);
+ Object.keys(vals).forEach((n) => {
+ const i = def.p.findIndex((p) => p.n === n);
+ if (i < 0) return;
+ const v = ccFromDisp(def.p[i], vals[n], syncOnForCc(def, cc));
+ if (v != null) cc[i] = v;
+ });
+ return { fx: num, cc, on: true };
+}
+const BLANK_BUS = { fx: 0, cc: [64, 64, 64, 64, 64, 64], on: false };
+const FACTORY_RECIPES = [
+ {
+ id: "rc-house-chord-synth",
+ name: "House Chord Synth (noir)",
+ buses: () => [
+ /* Overdrive -> Chorus -> Chromatic PS -> SX Reverb. Chromatic PS
+ does the actual "chord": +12/+7 semitones (octave + fifth) panned
+ apart, turning one held note into a chord-like stack. */
+ buildFactoryBus("bus12", "Overdrive", { DRIVE: 32, TONE: 67, BALANCE: "80/20", LEVEL: 80 }),
+ buildFactoryBus("bus12", "Chorus", { DEPTH: 35, RATE: 1.20, "EQ LOW": -15, "EQ HIGH": 3, BALANCE: "80/20", LEVEL: 100 }),
+ buildFactoryBus("bus34", "Chromatic PS", { PITCH1: 12, PITCH2: 7, PAN1: "L10", PAN2: "R10", BALANCE: "70/30" }),
+ buildFactoryBus("bus34", "SX Reverb", { TIME: 100, TONE: 100, BALANCE: "70/30" }),
+ BLANK_BUS,
+ ],
+ },
+ {
+ id: "rc-quack-bass",
+ name: "\"Quack\" Bass (noir)",
+ buses: () => [
+ buildFactoryBus("bus12", "Super Filter", { CUTOFF: 36, RESONANCE: 32, "FLT TYPE": "LPF", DEPTH: 100, SYNC: "ON", RATE: "1/2" }),
+ buildFactoryBus("bus12", "Phaser", { DEPTH: 29, RESONANCE: 24, MANUAL: 59, SYNC: "ON", RATE: 0.016, BALANCE: "50/50" }),
+ buildFactoryBus("bus34", "Compressor", { SUSTAIN: 12, ATTACK: 61, RATIO: 15, LEVEL: 100 }), /* RATIO: card says "10 to 20" */
+ buildFactoryBus("bus34", "Reverb", { TYPE: "HALL2", TIME: 60, LEVEL: 47, "LOW CUT": "315", "HIGH CUT": "5000" }),
+ BLANK_BUS,
+ ],
+ },
+ {
+ id: "rc-risers",
+ name: "Risers (noir)",
+ buses: () => [
+ buildFactoryBus("bus12", "Super Filter", { CUTOFF: 59, RESONANCE: 0, "FLT TYPE": "HPF", DEPTH: 0, SYNC: "OFF", RATE: 0 }),
+ buildFactoryBus("bus12", "Tremolo/Pan", { DEPTH: 36, WAVE: "TRI", TYPE: "PAN", SYNC: "ON", RATE: 0.300 }),
+ buildFactoryBus("bus34", "Overdrive", { DRIVE: 70, TONE: 40, BALANCE: "90/10", LEVEL: 100 }),
+ buildFactoryBus("bus34", "Ha-Dou", { "MOD DEPTH": 18, TIME: 100, LEVEL: 90, "LOW CUT": "250", "HIGH CUT": "5000" }),
+ BLANK_BUS,
+ ],
+ },
+ {
+ id: "rc-bass-destroyer",
+ name: "Bass Destroyer (noir)",
+ buses: () => [
+ buildFactoryBus("bus12", "Super Filter", { CUTOFF: 5, RESONANCE: 30, "FLT TYPE": "HPF" }), /* CUTOFF: card says "1 to 10" */
+ buildFactoryBus("bus12", "Distortion", { DRIVE: 48, TONE: -100, BALANCE: "90/10", LEVEL: 100 }),
+ buildFactoryBus("bus34", "JUNO Chorus", { MODE: "JX-1 2", NOISE: 0, BALANCE: "90/10" }),
+ buildFactoryBus("bus34", "Compressor", { SUSTAIN: 10, ATTACK: 47, RATIO: 8, LEVEL: 100 }),
+ BLANK_BUS,
+ ],
+ },
+ {
+ id: "rc-techno-kick-fattener",
+ name: "Techno Kick Fattener (noir)",
+ buses: () => [
+ /* CUTOFF/RESONANCE: card says "5 to 10" / "15 to 25" */
+ buildFactoryBus("bus12", "Super Filter", { CUTOFF: 8, RESONANCE: 20, "FLT TYPE": "HPF" }),
+ buildFactoryBus("bus12", "Equalizer", { "LOW GAIN": 4, "LOW FREQ": "100", "MID GAIN": -3, "MID FREQ": "200", "HIGH GAIN": 2, "HIGH FREQ": "2000" }),
+ buildFactoryBus("bus34", "Lo-fi", { "PRE FILT": 1, "LOFI TYPE": 9, TONE: 50, CUTOFF: "8000", BALANCE: "45/55", LEVEL: 100 }),
+ /* SUSTAIN/ATTACK/RATIO: card says "5 to 10" / "50 to 70" / "2 to 4" */
+ buildFactoryBus("bus34", "Compressor", { SUSTAIN: 8, ATTACK: 60, RATIO: 3 }),
+ BLANK_BUS,
+ ],
+ },
+ {
+ id: "rc-top-loops-sauce",
+ name: "Top Loops Sauce (noir)",
+ buses: () => [
+ buildFactoryBus("bus12", "Super Filter", { CUTOFF: 50, RESONANCE: 0, "FLT TYPE": "HPF", DEPTH: 0 }),
+ buildFactoryBus("bus12", "Tremolo/Pan", { DEPTH: 76, WAVE: "TRI", TYPE: "PAN", SYNC: "ON", RATE: 0.175 }), /* RATE: card says "0.150 to 0.200" */
+ buildFactoryBus("bus34", "Crusher", { FILTER: 9000, RATE: 0, BALANCE: "70/30" }), /* FILTER: card says "6000 to 12000" */
+ /* L/H DAMP F: card gives a range too wide for exact enum entries -
+ nearest available option within it */
+ buildFactoryBus("bus34", "Sync Delay", { TIME: "1/8D", FEEDBACK: 30, LEVEL: 30, "L DAMP F": "630", "H DAMP F": "6300" }),
+ BLANK_BUS,
+ ],
+ },
+];
/* ===== 04 STORE ===== */
const LS_KEY = "busdriver.v1";
@@ -1178,6 +1694,12 @@ 404 Busdriver
auto: { bars: 4, scope: "bank" },
midi: { outName: null, inName: null },
wake: false,
+ horizFaders: false, /* Setup toggle: sideways-drag full-width fader strips */
+ jamKnobs: false, /* Setup toggle: rotary knobs instead of bars for JAM faders */
+ theme: "dark", /* Setup toggle: "dark" | "light" */
+ jam: { slots: [] }, /* JAM tab: user-programmable pinned bus params + raw MIDI */
+ xyPresets: [], /* XY tab: user-saved axis combos, { id, name, x:{bus,slot}, y:{bus,slot}, mom } */
+ recipes: FACTORY_RECIPES.map((r) => ({ id: r.id, name: r.name, buses: r.buses() })), /* full 5-bus presets typed in from recipe cards, { id, name, buses:[{fx,cc[6],on} x5] } */
};
function directName(num) { /* num = CC#83 value 1..5 in the bus12 table */
const r = state.directFx[num - 1];
@@ -1209,6 +1731,51 @@ 404 Busdriver
}
let allOffSaved = null; /* [bool x5] while ALL OFF armed */
+const JAM_MAX = 24;
+/* validates + clamps a JAM slot loaded from storage/import; returns null to
+ drop anything malformed rather than let a corrupt slot crash rendering */
+function sanitizeJamSlot(c) {
+ if (!c || typeof c !== "object") return null;
+ if (c.kind !== "pad" && c.kind !== "fader") return null;
+ if (c.source !== "bus" && c.source !== "raw" && c.source !== "scene") return null;
+ const o = {
+ id: (typeof c.id === "string" && c.id) ? c.id : "j" + Date.now() + Math.random().toString(36).slice(2, 7),
+ kind: c.kind, source: c.source,
+ label: (typeof c.label === "string" ? c.label : "").slice(0, 16),
+ };
+ if (o.source === "scene") {
+ /* momentary/toggle recall of one bus's full state (fx + 6 cc + on/off) -
+ always a pad, a captured effect can't be smoothly dragged like a
+ single CC can */
+ if (!(c.bus >= 0 && c.bus < 5)) return null;
+ o.kind = "pad";
+ o.bus = c.bus | 0;
+ o.fx = clamp(c.fx | 0, 0, 127);
+ o.cc = Array.isArray(c.cc) && c.cc.length === 6 ? c.cc.map((v) => clamp(v | 0, 0, 127)) : [64, 64, 64, 64, 64, 64];
+ o.on = !!c.on;
+ o.mode = c.mode === "toggle" ? "toggle" : "momentary";
+ return o;
+ }
+ if (o.source === "bus") {
+ if (!(c.bus >= 0 && c.bus < 5)) return null;
+ if (!(c.ctrl >= 0 && c.ctrl < 6)) return null;
+ o.bus = c.bus | 0; o.ctrl = c.ctrl | 0;
+ } else {
+ o.port = (typeof c.port === "string" && c.port) ? c.port : null;
+ o.ch = clamp(c.ch | 0, 0, 15);
+ o.type = ["cc", "note", "pc"].indexOf(c.type) >= 0 ? c.type : "cc";
+ if (o.kind === "fader") o.type = "cc"; /* only CC is continuous */
+ o.num = clamp(c.num | 0, 0, 127);
+ o.val = clamp((c.val != null ? c.val : 64) | 0, 0, 127);
+ }
+ if (o.kind === "pad") {
+ o.mode = (c.mode === "momentary" || o.type === "pc") ? "momentary" : "toggle";
+ o.onVal = clamp((c.onVal != null ? c.onVal : 127) | 0, 0, 127);
+ o.offVal = clamp((c.offVal != null ? c.offVal : 0) | 0, 0, 127);
+ o.vel = clamp((c.vel != null ? c.vel : 110) | 0, 1, 127);
+ }
+ return o;
+}
function loadState() {
try {
const raw = localStorage.getItem(LS_KEY);
@@ -1286,12 +1853,35 @@ 404 Busdriver
if (s.recents) state.recents = Object.assign(state.recents, s.recents);
if (s.midi) state.midi = Object.assign(state.midi, s.midi);
state.wake = !!s.wake;
+ state.horizFaders = !!s.horizFaders;
+ state.jamKnobs = !!s.jamKnobs;
+ if (s.theme === "light" || s.theme === "dark") state.theme = s.theme;
+ if (s.jam && Array.isArray(s.jam.slots)) {
+ state.jam.slots = s.jam.slots.map(sanitizeJamSlot).filter(Boolean).slice(0, JAM_MAX);
+ }
+ if (Array.isArray(s.xyPresets)) {
+ state.xyPresets = s.xyPresets.map(sanitizeXyPreset).filter(Boolean).slice(0, XY_PRESETS_MAX);
+ }
+ if (Array.isArray(s.recipes)) {
+ state.recipes = s.recipes.map(sanitizeRecipe).filter(Boolean).slice(0, RECIPE_MAX);
+ }
+ /* one-time backfill: storage saved before a given factory recipe
+ existed (even just an empty recipes:[] from opening the panel once)
+ would otherwise silently override the in-code default above and
+ that card would never actually show up. Runs per-recipe so cards
+ added in a later update still get backfilled without duplicating
+ ones the user already has. */
+ const missing = FACTORY_RECIPES.filter((r) => !state.recipes.some((sr) => sr.id === r.id));
+ if (missing.length) {
+ state.recipes = [...missing.map((r) => ({ id: r.id, name: r.name, buses: r.buses() })), ...state.recipes].slice(0, RECIPE_MAX);
+ saveState(); /* don't depend on some later, unrelated action to persist this */
+ }
} catch (e) { /* corrupt storage: start fresh */ }
}
let saveTimer = null;
function writeState() {
try {
- localStorage.setItem(LS_KEY, JSON.stringify({ v: 1, buses: state.buses, mem: state.mem, favs: state.favs, recents: state.recents, snapshots: state.snapshots, directFx: state.directFx, xy: state.xy, lfos: state.lfos, tapBpm: state.tapBpm, ptn: state.ptn, play: state.play, auto: state.auto, midi: state.midi, wake: state.wake }));
+ localStorage.setItem(LS_KEY, JSON.stringify({ v: 1, buses: state.buses, mem: state.mem, favs: state.favs, recents: state.recents, snapshots: state.snapshots, directFx: state.directFx, xy: state.xy, lfos: state.lfos, tapBpm: state.tapBpm, ptn: state.ptn, play: state.play, auto: state.auto, midi: state.midi, wake: state.wake, horizFaders: state.horizFaders, jamKnobs: state.jamKnobs, jam: state.jam, xyPresets: state.xyPresets, recipes: state.recipes, theme: state.theme }));
} catch (e) { /* quota/private mode: ignore */ }
}
function saveState() {
@@ -1322,12 +1912,22 @@ 404 Busdriver
this.access.inputs.forEach((p) => { if (!found && p.name === state.midi.inName && this.live(p)) found = p; });
return found;
},
- sendBytes(bytes) {
+ /* any currently-connected output by name - lets custom controls (JAM tab)
+ target a second device (e.g. an MC-101) simultaneously with the SP-404,
+ which stays bound to state.midi.outName via out() as before */
+ outByName(name) {
+ if (!this.access) return null;
+ let found = null;
+ this.access.outputs.forEach((p) => { if (!found && p.name === name && this.live(p)) found = p; });
+ return found;
+ },
+ sendBytesTo(portName, bytes) {
if (this.demo) return true;
- const o = this.out();
+ const o = portName ? this.outByName(portName) : this.out();
if (!o) return false;
try { o.send(bytes); return true; } catch (e) { return false; }
},
+ sendBytes(bytes) { return this.sendBytesTo(null, bytes); },
sendPc(ch, pc) {
if (this.sendBytes([0xC0 | ch, pc & 0x7F])) blinkTx();
},
@@ -1346,6 +1946,22 @@ 404 Busdriver
this.echoLog.set(key, { val, t: performance.now() });
if (this.sendBytes([0xB0 | ch, ccNum, val])) blinkTx();
},
+ /* -To variants: same messages, but routed to an explicit port name
+ (null = primary SP-404 output) instead of always the primary port */
+ sendToPort(portName, ch, ccNum, val) {
+ val = clamp(val | 0, 0, 127);
+ /* log to echoLog same as send() - lets applyRawCC recognize this value
+ bouncing back from a device that echoes its own incoming CCs, instead
+ of misreading it as an external change a moment later */
+ this.echoLog.set(ch << 8 | ccNum, { val, t: performance.now() });
+ if (this.sendBytesTo(portName, [0xB0 | ch, ccNum, val])) blinkTx();
+ },
+ sendNoteTo(portName, ch, note, on, vel) {
+ if (this.sendBytesTo(portName, on ? [0x90 | ch, note & 0x7F, vel || 110] : [0x80 | ch, note & 0x7F, 0])) blinkTx();
+ },
+ sendPcTo(portName, ch, pc) {
+ if (this.sendBytesTo(portName, [0xC0 | ch, pc & 0x7F])) blinkTx();
+ },
async connect(interactive) {
if (!navigator.requestMIDIAccess) return;
try {
@@ -1400,6 +2016,10 @@ 404 Busdriver
if (rt) msg = Array.from(d).filter((b) => b < 0xF8);
if (msg.length < 3) return;
const type = msg[0] & 0xF0, ch = msg[0] & 0x0F;
+ /* JAM raw controls can be pinned to any channel (e.g. a second device),
+ so this check runs independently of the ch<=4/RECV_CCS gate below,
+ which is specific to the SP-404's own 5-bus CC set */
+ if (type === 0xB0) applyRawCC(ch, msg[1], msg[2]);
if (type !== 0xB0 || ch > 4 || !RECV_CCS.has(msg[1])) return;
handleBytes(ch, msg[1], msg[2]);
}
@@ -1439,6 +2059,43 @@ 404 Busdriver
}
}
+/* incoming CC from ANY connected input, matched against JAM raw controls by
+ channel+CC number - applyCC/handleBytes above is the SP-404-specific
+ pipeline (bus-indexed, only looks at the primary input's ch 0-4), so a
+ second device pinned in JAM (e.g. an MC-101's own hardware knobs on ch
+ 11-14) needs its own path that scans every connected port instead of
+ just the one selected as the primary SP-404 input. */
+const jamRawRenderQueue = new Set(); /* cfg.id values needing repaint */
+let jamRawFlushReq = 0;
+function queueJamRawRender(id) {
+ jamRawRenderQueue.add(id);
+ if (jamRawFlushReq) return;
+ jamRawFlushReq = requestAnimationFrame(() => {
+ jamRawFlushReq = 0;
+ const q = [...jamRawRenderQueue];
+ jamRawRenderQueue.clear();
+ q.forEach((rid) => {
+ const fns = jamRawUpdateFns.get(rid);
+ if (fns) fns.forEach((fn) => { try { fn(); } catch (e) { /* one broken control must never freeze the render loop */ } });
+ });
+ });
+}
+function applyRawCC(ch, ccNum, val) {
+ const key = ch << 8 | ccNum;
+ if (Midi.activeCCs.has(key)) return; /* a finger is dragging this control */
+ const e = Midi.echoLog.get(key);
+ if (e && e.val === val && performance.now() - e.t < 400) return; /* our own send bouncing back */
+ let touched = false;
+ state.jam.slots.forEach((cfg) => {
+ if (cfg.source === "raw" && cfg.type === "cc" && cfg.ch === ch && cfg.num === ccNum) {
+ cfg.val = clamp(val, 0, 127);
+ touched = true;
+ queueJamRawRender(cfg.id);
+ }
+ });
+ if (touched) saveState();
+}
+
/* ===== effect change sequence (the one deterministic sync point) ===== */
function memKey(table, num) {
if (table === "bus12" && num >= 1 && num <= 5) {
@@ -1519,6 +2176,14 @@ 404 Busdriver
/* drags must survive DOM rebuilds (device fx change, bus switch mid-drag):
otherwise activeCCs leaks and the CC goes deaf for device input */
const liveDrags = new Map(); /* key ch<<8|cc -> { node, finish } */
+/* JAM tiles pinned to a bus/ctrl slot need repainting on the same events as
+ the focus/overview faders for that slot (hardware encoder input, effect
+ switch, snapshot load) - keyed the same as renderQueue (bus*8+slot) */
+const jamByBusSlot = new Map(); /* key -> Set<() => void> */
+/* same idea for JAM raw controls pinned to a second device (e.g. an MC-101's
+ own hardware knobs) - keyed by the JAM slot's own id since raw controls
+ aren't tied to a bus/slot pair the way jamByBusSlot's keys are */
+const jamRawUpdateFns = new Map(); /* cfg.id -> Set<() => void> */
function finishDragsIn(host) {
for (const [k, d] of [...liveDrags]) {
if (host.contains(d.node)) d.finish();
@@ -1541,6 +2206,8 @@ 404 Busdriver
try {
if (controls[bus][slot]) controls[bus][slot].update();
if (stripControls[bus][slot]) stripControls[bus][slot].update();
+ const jc = jamByBusSlot.get(k);
+ if (jc) jc.forEach((fn) => fn());
} catch (e) { /* one broken control must never freeze the whole render loop */ }
}
});
@@ -1569,6 +2236,119 @@ 404 Busdriver
}
function closeStepper() { $("stepper").classList.remove("open"); }
+/* paints a fader's fill/thumb from a single 0-100 percent - orientation
+ (vertical vs. body.horiz-faders) is resolved entirely in CSS via --pct */
+function paintFader(fill, thumb, pct) {
+ fill.style.setProperty("--pct", pct + "%");
+ thumb.style.setProperty("--pct", pct + "%");
+}
+
+/* JAM's alternate "rotary knob" style (Setup > JAM style). Same 0-100 pct
+ as paintFader, remapped as a left/right tilt from straight-up (12
+ o'clock = the 0-127 midpoint, ~64): drag right of center to increase
+ toward 127, left of center to decrease toward 0, same balance-pot
+ convention as .fader's "bal" display. The arc fills from the 12 o'clock
+ reference out toward whichever side the pointer has tilted, so a
+ centered value shows an empty ring. */
+/* 135deg off vertical = the classic 270deg-total hardware-pot sweep (7:30
+ to 4:30), just re-centered so 12 o'clock is the value's midpoint instead
+ of the sweep's start - at +-60deg the fill barely showed at either
+ extreme, this reads as "maxed out" the way a real knob does. */
+const KNOB_MAX_TILT = 135;
+function paintKnob(dial, pointer, pct) {
+ const angle = (pct - 50) / 50 * KNOB_MAX_TILT;
+ pointer.style.transform = "rotate(" + angle + "deg)";
+ const lo = Math.min(0, angle), hi = Math.max(0, angle);
+ dial.style.background =
+ "conic-gradient(from " + lo + "deg, var(--accent) 0deg " + (hi - lo) + "deg, var(--track-bg) " + (hi - lo) + "deg 360deg)";
+}
+
+/* generic relative-drag fader engine, shared by bus focus faders and
+ custom MIDI faders (index.html custom tab). Orientation-aware: reads
+ body.horiz-faders so the same code drives vertical and sideways drag,
+ swapping which axis is "value" vs. "fine-tune pull". */
+function attachFaderDrag(fader, dom, opts) {
+ let drag = null, hideT = 0, lastTap = 0;
+ /* horiz-faders is scoped off for callers that pass allowHoriz:false (the
+ JAM grid: its tiles are narrow columns, a sideways fader has no travel
+ to work with there regardless of the Focus-view toggle) */
+ const horiz = () => opts.allowHoriz !== false && document.body.classList.contains("horiz-faders");
+ function finishDrag() {
+ if (!drag) return;
+ drag = null;
+ if (opts.key != null) liveDrags.delete(opts.key);
+ if (opts.echoKey != null) Midi.activeCCs.delete(opts.echoKey);
+ fader.classList.remove("drag");
+ hideT = setTimeout(() => { dom.big.classList.remove("show"); dom.big.style.marginLeft = ""; }, 800);
+ if (opts.trailingSend) opts.trailingSend();
+ }
+ fader.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ fader.setPointerCapture(e.pointerId);
+ const h = horiz();
+ drag = {
+ id: e.pointerId, startX: e.clientX, startY: e.clientY,
+ lastPos: h ? e.clientX : e.clientY, horiz: h,
+ acc: opts.get(), moved: 0,
+ /* sensitivity (finger travel for full 0-127 sweep) is normally derived
+ from the visible track size, floored at 60px - but a small tile
+ (JAM grid) would make that floor the operative value, giving a
+ twitchy few-cm sweep. opts.sensitivity overrides it with a fixed,
+ comfortable travel distance decoupled from how small the control
+ is drawn - the same principle as pointer capture already letting a
+ drag continue past the tile's edges. */
+ h: opts.sensitivity || Math.max(h ? dom.track.clientWidth : dom.track.clientHeight, 60),
+ };
+ if (opts.key != null) liveDrags.set(opts.key, { node: fader, finish: finishDrag });
+ if (opts.echoKey != null) Midi.activeCCs.add(opts.echoKey);
+ fader.classList.add("drag");
+ clearTimeout(hideT);
+ dom.big.classList.add("show");
+ /* clamp the big readout to the viewport (edge slots) */
+ const r = dom.big.getBoundingClientRect();
+ dom.big.style.marginLeft = (Math.max(0, 8 - r.left) - Math.max(0, r.right - window.innerWidth + 8)) + "px";
+ });
+ fader.addEventListener("pointermove", (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ const pos = drag.horiz ? e.clientX : e.clientY;
+ const perp = drag.horiz ? Math.abs(e.clientY - drag.startY) : Math.abs(e.clientX - drag.startX);
+ const fine = perp > 120 ? 0.1 : perp > 60 ? 0.25 : 1;
+ const dv = drag.horiz ? (pos - drag.lastPos) : (drag.lastPos - pos);
+ drag.lastPos = pos;
+ drag.moved = Math.max(drag.moved, Math.abs(e.clientY - drag.startY), Math.abs(e.clientX - drag.startX));
+ drag.acc = clamp(drag.acc + dv * fine * 127 / drag.h, 0, 127);
+ const target = opts.snapN ? ccFromEnum(enumFromCc(Math.round(drag.acc), opts.snapN), opts.snapN) : Math.round(drag.acc);
+ if (target !== opts.get()) opts.set(target, {});
+ });
+ function endDrag(e) {
+ if (!drag || e.pointerId !== drag.id) return;
+ const moved = drag.moved;
+ finishDrag();
+ if (moved < 6) {
+ const now = performance.now();
+ if (now - lastTap < 300) { /* double tap -> default */
+ if (opts.onDefault) opts.set(opts.onDefault(), { force: true });
+ lastTap = 0;
+ } else lastTap = now;
+ }
+ }
+ fader.addEventListener("pointerup", endDrag);
+ fader.addEventListener("pointercancel", endDrag);
+ fader.addEventListener("wheel", (e) => {
+ e.preventDefault();
+ const step = (e.shiftKey ? 10 : 1) * (e.deltaY < 0 ? 1 : -1);
+ opts.set(opts.get() + step, { force: true });
+ }, { passive: false });
+ if (dom.val && opts.onStepperOpen) {
+ dom.val.addEventListener("pointerdown", (e) => {
+ e.stopPropagation(); e.preventDefault();
+ const r = dom.val.getBoundingClientRect();
+ opts.onStepperOpen(r.left + r.width / 2, r.top);
+ });
+ }
+ return { finishDrag };
+}
+
/* one control in a slot; kind: "fader" | "snap" | "seg" | "toggle" */
function buildControl(slotEl, bus, slot, mini) {
const B = BUSES[bus];
@@ -1646,10 +2426,8 @@ 404 Busdriver
function curCc() { return state.buses[bus].cc[slot]; }
function disp(cc) { return dispFromCc(p, cc, syncOnFor(bus, def)); }
function update() {
- const cc = curCc(), pct = cc / 127 * 100;
- fill.style.height = pct + "%";
- /* travel limited to track minus thumb height so the thumb never leaves the track */
- thumb.style.bottom = "calc(" + (cc / 127) + " * (100% - var(--thumb-h)))";
+ const cc = curCc();
+ paintFader(fill, thumb, cc / 127 * 100);
const d = disp(cc);
val.textContent = d;
big.textContent = d;
@@ -1658,69 +2436,14 @@ 404 Busdriver
update();
const key = B.ch << 8 | CC_CTRL[slot];
- let drag = null, hideT = 0, lastTap = 0;
- function finishDrag() {
- /* idempotent cleanup; also runs when a rebuild removes this fader mid-drag */
- if (!drag) return;
- drag = null;
- liveDrags.delete(key);
- Midi.activeCCs.delete(key);
- fader.classList.remove("drag");
- hideT = setTimeout(() => { big.classList.remove("show"); big.style.marginLeft = ""; }, 800);
- /* trailing send: guarantee final value reaches the device */
- Midi.send(B.ch, CC_CTRL[slot], curCc(), true);
- }
- fader.addEventListener("pointerdown", (e) => {
- e.preventDefault();
- fader.setPointerCapture(e.pointerId);
- drag = { id: e.pointerId, lastY: e.clientY, startX: e.clientX, startY: e.clientY,
- acc: curCc(), moved: 0, h: Math.max(track.clientHeight, 60) };
- Midi.activeCCs.add(key);
- liveDrags.set(key, { node: fader, finish: finishDrag });
- fader.classList.add("drag");
- clearTimeout(hideT);
- big.classList.add("show");
- /* clamp the big readout to the viewport (edge slots) */
- const r = big.getBoundingClientRect();
- big.style.marginLeft = (Math.max(0, 8 - r.left) - Math.max(0, r.right - window.innerWidth + 8)) + "px";
- });
- fader.addEventListener("pointermove", (e) => {
- if (!drag || e.pointerId !== drag.id) return;
- const dx = Math.abs(e.clientX - drag.startX);
- const fine = dx > 120 ? 0.1 : dx > 60 ? 0.25 : 1;
- const dy = drag.lastY - e.clientY;
- drag.lastY = e.clientY;
- drag.moved = Math.max(drag.moved, Math.abs(e.clientY - drag.startY), dx);
- drag.acc = clamp(drag.acc + dy * fine * 127 / drag.h, 0, 127);
- const target = snapN ? ccFromEnum(enumFromCc(Math.round(drag.acc), snapN), snapN) : Math.round(drag.acc);
- if (target !== curCc()) setBusCc(bus, slot, target, {});
+ attachFaderDrag(fader, { val: mini ? null : val, big, track }, {
+ key, echoKey: key, snapN,
+ get: curCc,
+ set: (v, o) => setBusCc(bus, slot, v, o),
+ onDefault: () => ccFromDefault(p),
+ trailingSend: () => Midi.send(B.ch, CC_CTRL[slot], curCc(), true),
+ onStepperOpen: mini ? null : (x, y) => openStepper(x, y, bus, slot),
});
- function endDrag(e) {
- if (!drag || e.pointerId !== drag.id) return;
- const moved = drag.moved;
- finishDrag();
- if (moved < 6) {
- const now = performance.now();
- if (now - lastTap < 300) { /* double tap -> default */
- setBusCc(bus, slot, ccFromDefault(p), { force: true });
- lastTap = 0;
- } else lastTap = now;
- }
- }
- fader.addEventListener("pointerup", endDrag);
- fader.addEventListener("pointercancel", endDrag);
- fader.addEventListener("wheel", (e) => {
- e.preventDefault();
- const step = (e.shiftKey ? 10 : 1) * (e.deltaY < 0 ? 1 : -1);
- setBusCc(bus, slot, curCc() + step, { force: true });
- }, { passive: false });
- if (!mini) {
- val.addEventListener("pointerdown", (e) => {
- e.stopPropagation(); e.preventDefault();
- const r = val.getBoundingClientRect();
- openStepper(r.left + r.width / 2, r.top, bus, slot);
- });
- }
return api;
}
@@ -1737,6 +2460,43 @@ 404 Busdriver
}
/* other buses' focus controls are stale references now */
BUSES.forEach((_, b) => { if (b !== bus) controls[b] = [null, null, null, null, null, null]; });
+ renderFocusScenes();
+}
+/* saved bus-scene JAM pads for the active bus, recalled right here instead
+ of needing a trip to the JAM tab - same cfg objects, same attachJamPad
+ interaction (hold/toggle), just a second place they're rendered.
+ Pressing one of these applies a full bus state -> renderBus() -> (since
+ it's the active bus) renderFocusSlots() -> this function again, all while
+ the finger is still down. A naive rebuild here would tear down the very
+ button mid-press, breaking its pointer capture before release ever fires
+ (release lands on a fresh button whose "down" never went true). The
+ signature check makes this a no-op unless the actual scene *set* (which
+ bus, which scenes, their labels) changed, so applying a scene - which
+ only changes bus values, not the JAM list - never touches the DOM. */
+let lastFocusScenesSig = null;
+function renderFocusScenes() {
+ const host = $("focus-scenes");
+ if (!host) return;
+ const scenes = state.jam.slots.filter((cfg) => cfg.source === "scene" && cfg.bus === state.activeBus);
+ const sig = state.activeBus + ":" + scenes.map((c) => c.id + "=" + c.label).join(",");
+ if (sig === lastFocusScenesSig) return;
+ lastFocusScenesSig = sig;
+ host.textContent = "";
+ host.classList.toggle("has-scenes", scenes.length > 0);
+ scenes.forEach((cfg) => {
+ const item = el("div", "focus-scene-item");
+ const btn = el("button", "pad focus-scene-btn", jamLabel(cfg));
+ item.appendChild(btn);
+ const editBtn = el("button", "focus-scene-edit", "✎");
+ editBtn.addEventListener("pointerdown", (e) => {
+ e.preventDefault(); e.stopPropagation();
+ const idx = state.jam.slots.indexOf(cfg);
+ if (idx >= 0) openJamEditor(idx);
+ });
+ item.appendChild(editBtn);
+ host.appendChild(item);
+ attachJamPad(btn, cfg);
+ });
}
function updateFocusHead() {
const bus = state.activeBus, b = state.buses[bus], B = BUSES[bus];
@@ -1744,6 +2504,33 @@ 404 Busdriver
$("efx-pad").textContent = b.on ? "EFX ON" : "EFX";
$("fx-name").querySelector(".fx-title").textContent = fxDisplayName(B.table, b.fx);
$("fx-name").querySelector(".fx-sub").textContent = B.label + " · choose effect";
+ renderFocusFavs();
+}
+/* one-tap favorite-effect chips for the bus currently shown in Focus - the
+ same favorites the picker's FAVORITES section and its "FA" jump button
+ surface, just reachable without opening the picker at all. Re-rendered
+ from updateFocusHead() (covers setActiveBus/renderBus/bus switches) and
+ from the picker's star-hold handler (favoriting doesn't change fx, so
+ nothing else would refresh this strip). */
+function renderFocusFavs() {
+ const host = $("focus-favs");
+ if (!host) return;
+ const bus = state.activeBus, B = BUSES[bus], table = B.table;
+ const nums = favList(table).filter((n) => n < BUS_TABLES[table].length);
+ host.classList.toggle("show", nums.length > 0);
+ host.textContent = "";
+ nums.forEach((n) => {
+ const chip = el("button", "focus-fav-chip" + (n === state.buses[bus].fx ? " sel" : ""), fxNameFor(table, n));
+ chip.addEventListener("click", () => {
+ fxChange(bus, n, "user");
+ /* a favorite chip is for loading an effect to go tweak it, not for
+ punching it in live - always land with EFX off so nothing is heard
+ until you deliberately hit EFX, unlike the picker (which is a more
+ deliberate action to begin with) */
+ sendSw(bus, false);
+ });
+ host.appendChild(chip);
+ });
}
function updateBusChrome(bus) {
const b = state.buses[bus];
@@ -1768,19 +2555,21 @@ 404 Busdriver
}
function currentView() {
const b = document.body.classList;
- return b.contains("xy") ? "xy" : b.contains("pattern") ? "pattern" : b.contains("play") ? "play" : b.contains("overview") ? "overview" : "focus";
+ return b.contains("xy") ? "xy" : b.contains("pattern") ? "pattern" : b.contains("play") ? "play" : b.contains("jam") ? "jam" : b.contains("overview") ? "overview" : "focus";
}
function setView(v) {
const b = document.body.classList;
- b.remove("overview", "xy", "pattern", "play");
+ b.remove("overview", "xy", "pattern", "play", "jam");
if (v !== "focus") b.add(v);
$("xy-btn").classList.toggle("on", v === "xy");
if (patternTab) patternTab.classList.toggle("active", v === "pattern");
if (playTab) playTab.classList.toggle("active", v === "play");
+ if (jamTab) jamTab.classList.toggle("active", v === "jam");
$("view-toggle").textContent = v === "overview" ? "Focus" : "Overview";
if (v === "xy") renderXy();
if (v === "pattern") renderPattern();
if (v === "play") renderPlay();
+ if (v === "jam") buildJam();
}
function gotoFocus(bus) {
setActiveBus(bus);
@@ -1792,7 +2581,7 @@ 404 Busdriver
/* bus tabs */
const tabs = [], tabLeds = [];
-let patternTab = null, playTab = null;
+let patternTab = null, playTab = null, jamTab = null;
function buildTabs() {
const host = $("bustabs");
BUSES.forEach((B, i) => {
@@ -1826,6 +2615,14 @@ 404 Busdriver
});
host.appendChild(pt);
patternTab = pt;
+ const jm = el("button", "bustab");
+ jm.appendChild(el("span", "", "JAM"));
+ jm.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ setView(currentView() === "jam" ? "focus" : "jam");
+ });
+ host.appendChild(jm);
+ jamTab = jm;
}
/* overview strips */
@@ -1953,6 +2750,25 @@ 404 Busdriver
});
saveState();
}
+/* per-bus instant apply (fx + 6 cc + on/off) - same staggered-send shape as
+ applyFullState but scoped to one bus and its own timing (kept separate
+ from applyFullState rather than refactored into it, so the already-relied
+ -on multi-bus snapshot load path stays untouched). Used by JAM scene pads
+ to jump into a captured bus state on press and back out on release. */
+function applyBusState(bus, src) {
+ const B = BUSES[bus], cur = state.buses[bus];
+ fxTimers[bus].forEach(clearTimeout); fxTimers[bus] = [];
+ if (cur.fx > 0) state.mem[memKey(B.table, cur.fx)] = cur.cc.slice();
+ cur.fx = src.fx | 0;
+ cur.cc = src.cc.map((v) => clamp(v | 0, 0, 127));
+ cur.on = !!src.on;
+ let d = 0;
+ fxTimers[bus].push(setTimeout(() => Midi.send(B.ch, CC_FX, cur.fx, true), d)); d += 6;
+ cur.cc.forEach((v, j) => { fxTimers[bus].push(setTimeout(() => Midi.send(B.ch, CC_CTRL[j], v, true), d)); d += 6; });
+ fxTimers[bus].push(setTimeout(() => Midi.send(B.ch, CC_SW, cur.on ? 127 : 0, true), d)); d += 6;
+ renderBus(bus);
+ saveState();
+}
function busesSummary(buses, compact) {
/* compact: "1● Tape Echo · 3○ Lo-fi" (● an, ○ gesetzt aber aus) */
const parts = [];
@@ -2016,6 +2832,7 @@ 404 Busdriver
}
let snapEls = [];
function renderSnaps() {
+ updateImportBtn();
$("snap-current").textContent = busesSummary(state.buses, false);
const grid = $("snap-grid");
grid.textContent = "";
@@ -2064,7 +2881,7 @@ 404 Busdriver
}
function doExport() {
const ta = $("exchange");
- ta.value = JSON.stringify({ busdriver: 1, snapshots: state.snapshots }, null, 1);
+ ta.value = JSON.stringify({ busdriver: 1, snapshots: state.snapshots, jam: state.jam.slots, xyPresets: state.xyPresets, recipes: state.recipes }, null, 1);
ta.focus(); ta.select();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(ta.value)
@@ -2072,26 +2889,237 @@ 404 Busdriver
.catch(() => {});
}
}
+/* same window.confirm() unreliability as clearJam() (see its comment) -
+ act immediately and allow undo instead of gating on a dialog that some
+ Web MIDI wrapper browsers silently stub to false */
+let importUndo = null; /* { snapshots, jam, xyPresets, recipes, t } */
function doImport() {
+ if (importUndo && performance.now() - importUndo.t < 8000) {
+ state.snapshots = importUndo.snapshots;
+ state.jam.slots = importUndo.jam;
+ state.xyPresets = importUndo.xyPresets;
+ state.recipes = importUndo.recipes;
+ importUndo = null;
+ saveState(); scheduleSync(); renderSnaps(); buildJam();
+ updateImportBtn();
+ showBanner("Import undone", "info", 2000);
+ return;
+ }
let d = null;
try { d = JSON.parse($("exchange").value); } catch (e) {}
- if (!d || d.busdriver !== 1 || !Array.isArray(d.snapshots)) {
+ if (!d || d.busdriver !== 1 || (!Array.isArray(d.snapshots) && !Array.isArray(d.jam) && !Array.isArray(d.xyPresets) && !Array.isArray(d.recipes))) {
showBanner("No valid Busdriver JSON in the text field", "warn", 3000);
return;
}
- let ok = true;
- try { ok = window.confirm("Import overwrites matching slot numbers from the JSON. Continue?"); } catch (e) {}
- if (!ok) return;
+ importUndo = { snapshots: state.snapshots.slice(), jam: state.jam.slots.slice(), xyPresets: state.xyPresets.slice(), recipes: state.recipes.slice(), t: performance.now() };
let n = 0;
- for (let i = 0; i < 8; i++) {
- const r = d.snapshots[i];
- if (r && !r.del && Array.isArray(r.buses)) {
- state.snapshots[i] = { t: Date.now(), name: String(r.name || "Import " + (i + 1)).slice(0, 24), buses: r.buses };
- n++;
+ if (Array.isArray(d.snapshots)) {
+ for (let i = 0; i < 8; i++) {
+ const r = d.snapshots[i];
+ if (r && !r.del && Array.isArray(r.buses)) {
+ state.snapshots[i] = { t: Date.now(), name: String(r.name || "Import " + (i + 1)).slice(0, 24), buses: r.buses };
+ n++;
+ }
}
}
+ let jn = 0;
+ if (Array.isArray(d.jam)) {
+ state.jam.slots = d.jam.map(sanitizeJamSlot).filter(Boolean).slice(0, JAM_MAX);
+ jn = state.jam.slots.length;
+ buildJam();
+ }
+ let xn = 0;
+ if (Array.isArray(d.xyPresets)) {
+ state.xyPresets = d.xyPresets.map(sanitizeXyPreset).filter(Boolean).slice(0, XY_PRESETS_MAX);
+ xn = state.xyPresets.length;
+ renderXyCustomList();
+ }
+ let rn = 0;
+ if (Array.isArray(d.recipes)) {
+ state.recipes = d.recipes.map(sanitizeRecipe).filter(Boolean).slice(0, RECIPE_MAX);
+ rn = state.recipes.length;
+ renderRecipesList();
+ }
saveState(); scheduleSync(); renderSnaps();
- showBanner(n + " snapshot(s) imported", "info", 2500);
+ showBanner(n + " snapshot(s), " + jn + " JAM control(s), " + xn + " XY combo(s), " + rn + " recipe(s) imported – tap Import again within 8s to undo", "info", 3500);
+ updateImportBtn();
+ setTimeout(updateImportBtn, 8100);
+}
+function updateImportBtn() {
+ const btn = $("imp-btn");
+ if (!btn) return;
+ const active = importUndo && performance.now() - importUndo.t < 8000;
+ btn.textContent = active ? "Undo Import" : "Import";
+}
+
+/* ===== Recipes: full 5-bus presets typed in directly (e.g. from an
+ SP-404 "recipe card" pack) rather than captured from live state like
+ Snapshots. Same {fx, cc[6], on} per-bus shape as a Snapshot's buses
+ array and loaded the same way (applyFullState), just entered by hand -
+ the whole point is letting a card's own printed values (dB, ms, %, note
+ names) go straight into the app via ccFromDisp instead of eyeballing
+ knob positions. Not capped at 8 like the Snapshot slots. ===== */
+const RECIPE_MAX = 60;
+function sanitizeRecipe(r) {
+ if (!r || typeof r !== "object" || !Array.isArray(r.buses) || r.buses.length !== 5) return null;
+ const buses = r.buses.map((b) => ({
+ fx: clamp((b && b.fx | 0) || 0, 0, 127),
+ cc: (b && Array.isArray(b.cc) && b.cc.length === 6) ? b.cc.map((v) => clamp(v | 0, 0, 127)) : [64, 64, 64, 64, 64, 64],
+ on: !!(b && b.on),
+ }));
+ return {
+ id: (typeof r.id === "string" && r.id) ? r.id : "rc" + Date.now() + Math.random().toString(36).slice(2, 7),
+ name: (typeof r.name === "string" && r.name.trim()) ? r.name.slice(0, 30) : "Recipe",
+ buses,
+ };
+}
+function blankRecipeBuses() {
+ return BUSES.map(() => ({ fx: 0, cc: [64, 64, 64, 64, 64, 64], on: false }));
+}
+function openRecipes() {
+ renderRecipesList();
+ $("recipes").classList.add("open");
+}
+function renderRecipesList() {
+ const host = $("recipes-list");
+ if (!host) return;
+ host.textContent = "";
+ if (!state.recipes.length) {
+ host.appendChild(el("p", "pick-note", "No recipes yet — tap “+ New Recipe” below and type one in from a card."));
+ return;
+ }
+ state.recipes.forEach((r, i) => {
+ const row = el("div", "xy-custom-row");
+ const nameBtn = el("button", "setup-btn xy-custom-name", r.name);
+ nameBtn.addEventListener("click", () => loadRecipe(r));
+ const ren = el("button", "xy-custom-icon", "✎");
+ ren.addEventListener("click", (e) => { e.stopPropagation(); openRecipeEditor(i); });
+ const del = el("button", "xy-custom-icon", "✕");
+ del.addEventListener("click", (e) => { e.stopPropagation(); deleteRecipe(r.id); });
+ row.appendChild(nameBtn); row.appendChild(ren); row.appendChild(del);
+ host.appendChild(row);
+ });
+}
+function loadRecipe(r) {
+ applyFullState(r.buses);
+ $("recipes").classList.remove("open");
+ showBanner("\"" + r.name + "\" loaded", "info", 2000);
+}
+function deleteRecipe(id) {
+ state.recipes = state.recipes.filter((r) => r.id !== id);
+ saveState();
+ renderRecipesList();
+}
+let recipeDraft = null; /* { id, name, buses[5] } while the editor is open */
+let recipeEditIndex = null;
+function openRecipeEditor(index) {
+ recipeEditIndex = index;
+ const r = (index != null) ? state.recipes[index] : null;
+ recipeDraft = r
+ ? { id: r.id, name: r.name, buses: r.buses.map((b) => ({ fx: b.fx, cc: b.cc.slice(), on: b.on })) }
+ : { id: null, name: "Recipe " + (state.recipes.length + 1), buses: blankRecipeBuses() };
+ $("recipe-edit-title").textContent = (index != null) ? "EDIT RECIPE" : "NEW RECIPE";
+ $("re-name").value = recipeDraft.name;
+ renderRecipeBuses();
+ $("recipes").classList.remove("open");
+ $("recipe-edit").classList.add("open");
+}
+function renderRecipeBuses() {
+ const host = $("re-buses");
+ host.textContent = "";
+ BUSES.forEach((B, bus) => {
+ const draft = recipeDraft.buses[bus];
+ const table = BUS_TABLES[B.table];
+ const def = fxDefFor(B.table, draft.fx);
+ const block = el("div", "re-bus-block");
+ block.appendChild(el("h4", "", B.label));
+ const sel = el("select", "re-fx-select");
+ table.forEach((name, num) => {
+ const o = document.createElement("option");
+ o.value = String(num); o.textContent = name;
+ if (num === draft.fx) o.selected = true;
+ sel.appendChild(o);
+ });
+ sel.addEventListener("change", () => {
+ draft.fx = +sel.value;
+ /* start from that effect's own defaults - a leftover cc set from
+ whatever the previous effect was here means nothing on the new one */
+ draft.cc = defaultCcs(fxDefFor(B.table, draft.fx));
+ renderRecipeBuses();
+ });
+ block.appendChild(sel);
+ const onRow = el("label", "re-onoff");
+ const onCb = document.createElement("input");
+ onCb.type = "checkbox"; onCb.checked = draft.on;
+ onCb.addEventListener("change", () => { draft.on = onCb.checked; });
+ onRow.appendChild(onCb);
+ onRow.appendChild(document.createTextNode("EFX on"));
+ block.appendChild(onRow);
+ if (def) {
+ const fields = el("div", "re-fields");
+ def.p.forEach((p, i) => {
+ if (i >= 6) return;
+ const syncOn = syncOnForCc(def, draft.cc);
+ const field = el("label", "je-field", p.n + (p.u ? " (" + p.u + ")" : ""));
+ if (p.t === "e") {
+ const s = document.createElement("select");
+ s.className = "re-fx-select";
+ p.v.forEach((opt, oi) => {
+ const o = document.createElement("option");
+ o.value = String(oi); o.textContent = opt;
+ if (oi === enumFromCc(draft.cc[i], p.v.length)) o.selected = true;
+ s.appendChild(o);
+ });
+ s.addEventListener("change", () => { draft.cc[i] = ccFromEnum(+s.value, p.v.length); });
+ field.appendChild(s);
+ } else if (p.t === "b") {
+ const cb = document.createElement("input");
+ cb.type = "checkbox"; cb.checked = draft.cc[i] >= 64;
+ cb.addEventListener("change", () => {
+ draft.cc[i] = cb.checked ? 127 : 0;
+ if (p.n === "SYNC") renderRecipeBuses(); /* flips sibling fields between ms/note display */
+ });
+ field.appendChild(cb);
+ } else if (p.t === "c" && p.sv && syncOn && Array.isArray(p.sv)) {
+ const s = document.createElement("select");
+ s.className = "re-fx-select";
+ p.sv.forEach((opt, oi) => {
+ const o = document.createElement("option");
+ o.value = String(oi); o.textContent = opt;
+ if (oi === enumFromCc(draft.cc[i], p.sv.length)) o.selected = true;
+ s.appendChild(o);
+ });
+ s.addEventListener("change", () => { draft.cc[i] = ccFromEnum(+s.value, p.sv.length); });
+ field.appendChild(s);
+ } else {
+ const inp = document.createElement("input");
+ inp.type = "text"; inp.inputMode = "decimal";
+ inp.value = dispFromCc(p, draft.cc[i], syncOn);
+ inp.addEventListener("change", () => {
+ const v = ccFromDisp(p, inp.value, syncOn);
+ if (v != null) draft.cc[i] = v;
+ });
+ field.appendChild(inp);
+ }
+ fields.appendChild(field);
+ });
+ block.appendChild(fields);
+ }
+ host.appendChild(block);
+ });
+}
+function saveRecipeDraft() {
+ const name = $("re-name").value.trim().slice(0, 30) || recipeDraft.name;
+ const saved = { id: recipeDraft.id || ("rc" + Date.now() + Math.random().toString(36).slice(2, 7)), name, buses: recipeDraft.buses };
+ if (recipeEditIndex != null) state.recipes[recipeEditIndex] = saved;
+ else {
+ if (state.recipes.length >= RECIPE_MAX) { showBanner("Recipe list is full (" + RECIPE_MAX + ") — delete one first", "warn", 3000); return; }
+ state.recipes.push(saved);
+ }
+ saveState();
+ $("recipe-edit").classList.remove("open");
+ openRecipes();
+ showBanner("\"" + name + "\" saved", "info", 2000);
}
/* ===== randomizer (one-step undo via second tap within 6 s) ===== */
@@ -2202,6 +3230,7 @@ 404 Busdriver
pressFired = true;
state.favs[B.table][num] = { on: !isFav(B.table, num), t: Date.now() };
saveState(); scheduleSync(); openPicker(bus); /* re-render, keep open */
+ renderFocusFavs(); /* favoriting doesn't change fx, nothing else would refresh this */
}, 420);
}
});
@@ -2234,17 +3263,36 @@ 404 Busdriver
grid0.appendChild(mkItem(0));
body.appendChild(grid0);
+ /* jump bar built up front so RECENT/FAVORITES get a shortcut too, same as
+ the categories below - favorites in particular can end up far down a
+ long list (e.g. bus34's 40 effects), so it needs the same one-tap
+ jump the categories already get, not just a scroll */
+ const oldJump = $("jumpbar");
+ if (oldJump) oldJump.remove();
+ const jump = el("div", ""); jump.id = "jumpbar";
+ const addJumpBtn = (label, sec) => {
+ const jb = el("button", "", label);
+ jb.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ sec.scrollIntoView({ block: "start", behavior: "smooth" });
+ });
+ jump.appendChild(jb);
+ };
+
const addRow = (title, nums) => {
- if (!nums.length) return;
+ if (!nums.length) return null;
const cat = el("div", "pick-cat");
cat.appendChild(el("h3", "", title));
const g = el("div", "pick-grid");
nums.forEach((n) => g.appendChild(mkItem(n)));
cat.appendChild(g);
body.appendChild(cat);
+ return cat;
};
- addRow("RECENT", state.recents[B.table].filter((n) => n < table.length));
- addRow("FAVORITES", favList(B.table).filter((n) => n < table.length));
+ const recentSec = addRow("RECENT", state.recents[B.table].filter((n) => n < table.length));
+ const favSec = addRow("FAVORITES", favList(B.table).filter((n) => n < table.length));
+ if (recentSec) addJumpBtn("RE", recentSec);
+ if (favSec) addJumpBtn("FA", favSec);
const catSecs = {};
CAT_ORDER.forEach((c) => { catSecs[c] = []; });
@@ -2253,9 +3301,6 @@ 404 Busdriver
const c = def ? def.c : "weird";
(catSecs[c] || catSecs.weird).push(n);
}
- const oldJump = $("jumpbar");
- if (oldJump) oldJump.remove();
- const jump = el("div", ""); jump.id = "jumpbar";
CAT_ORDER.forEach((c) => {
if (!catSecs[c].length) return;
const sec = el("div", "pick-cat");
@@ -2265,12 +3310,7 @@ 404 Busdriver
catSecs[c].forEach((n) => g.appendChild(mkItem(n)));
sec.appendChild(g);
body.appendChild(sec);
- const jb = el("button", "", CAT_LABELS[c].slice(0, 2));
- jb.addEventListener("pointerdown", (e) => {
- e.preventDefault();
- sec.scrollIntoView({ block: "start", behavior: "smooth" });
- });
- jump.appendChild(jb);
+ addJumpBtn(CAT_LABELS[c].slice(0, 2), sec);
});
$("picker-panel").appendChild(jump); /* outside the scroller so it stays put */
$("picker").classList.add("open");
@@ -2306,6 +3346,9 @@ 404 Busdriver
mkPortBtns($("out-list"), outs, state.midi.outName, (n) => { state.midi.outName = n; });
mkPortBtns($("in-list"), ins, state.midi.inName, (n) => { state.midi.inName = n; });
$("wake-toggle").textContent = "Keep screen awake: " + (state.wake ? "on" : "off");
+ $("horiz-toggle").textContent = "Horizontal faders: " + (state.horizFaders ? "on" : "off");
+ $("jam-knob-toggle").textContent = "JAM style: " + (state.jamKnobs ? "knobs" : "bars");
+ $("theme-toggle").textContent = "Theme: " + state.theme;
const dl = $("dfx-list");
dl.textContent = "";
for (let i = 0; i < 5; i++) {
@@ -2343,6 +3386,12 @@ 404 Busdriver
} catch (e) { /* not granted (battery, platform): ignore */ }
}
+function applyTheme() {
+ document.documentElement.dataset.theme = state.theme;
+ const meta = $("theme-color-meta");
+ if (meta) meta.content = state.theme === "light" ? "#f3f2ee" : "#121214";
+}
+
/* ===== MIDI clock -> BPM (Link route: any connected port may carry clock) ===== */
const Clock = { src: null, last: 0, ticks: [], count: 0 };
function onClockByte(st, srcName) {
@@ -2365,7 +3414,14 @@ 404 Busdriver
if (window.busdriver && window.busdriver.monitor && d[0] !== 0xF8 && d[0] !== 0xFE)
console.log("MIDI in (" + src + "):",
Array.from(d).map((b) => b.toString(16).padStart(2, "0")).join(" "));
- for (let i = 0; i < d.length; i++) if (d[i] >= 0xF8) onClockByte(d[i], src);
+ let msg = d, rt = false;
+ for (let i = 0; i < d.length; i++) if (d[i] >= 0xF8) { rt = true; onClockByte(d[i], src); }
+ /* despite the name, this port isn't ONLY for clock - a second device
+ pinned in JAM (e.g. an MC-101) is very likely connected as one of
+ these non-primary ports, not as the one selected SP-404 input */
+ if (rt) msg = Array.from(d).filter((b) => b < 0xF8);
+ if (msg.length < 3) return;
+ if ((msg[0] & 0xF0) === 0xB0) applyRawCC(msg[0] & 0x0F, msg[1], msg[2]);
}
setInterval(() => {
const bpmEl = $("bpm");
@@ -2486,6 +3542,154 @@ 404 Busdriver
pad.addEventListener("pointerup", end);
pad.addEventListener("pointercancel", end);
}
+
+/* ===== XY presets: curated templates (real parameter names, like the JAM
+ presets) plus the user's own saved axis combos ===== */
+const XY_TEMPLATES = [
+ { id: "isolator", name: "Isolator — LOW × HIGH", fx: "Isolator", x: "LOW", y: "HIGH",
+ note: "Cut the lows on one side, the highs on the other — park in a corner to isolate the mids." },
+ { id: "super-filter", name: "Super Filter — CUTOFF × RESONANCE", fx: "Super Filter", x: "CUTOFF", y: "RESONANCE",
+ note: "Classic filter sweep, with resonance piling on as you go." },
+ { id: "tape-echo", name: "Tape Echo — TIME × FEEDBACK", fx: "Tape Echo", x: "TIME", y: "FEEDBACK",
+ note: "Drag right for longer echoes, up for more repeats." },
+ { id: "phaser", name: "Phaser — RATE × DEPTH", fx: "Phaser", x: "RATE", y: "DEPTH",
+ note: "How fast it sweeps vs. how far — anywhere from a subtle swirl to full jet-flange." },
+ { id: "wah", name: "Wah — MANUAL × PEAK", fx: "Wah", x: "MANUAL", y: "PEAK",
+ note: "Pedal position and resonance peak in one gesture, DJ-scratch style." },
+ { id: "resonator", name: "Resonator — BRIGHT × FEEDBACK", fx: "Resonator", x: "BRIGHT", y: "FEEDBACK",
+ note: "Tone and ring-out length of the pitched resonance." },
+];
+function xyTemplateBuses(t) {
+ return BUSES.map((B, i) => (BUS_TABLES[B.table].includes(t.fx) ? i : null)).filter((i) => i !== null);
+}
+const XY_PRESETS_MAX = 16;
+function sanitizeXyPreset(p) {
+ if (!p || typeof p !== "object") return null;
+ const axis = (a) => (a && a.bus >= 0 && a.bus < 5 && a.slot >= 0 && a.slot < 6) ? { bus: a.bus | 0, slot: a.slot | 0 } : null;
+ const x = axis(p.x), y = axis(p.y);
+ if (!x || !y) return null;
+ return {
+ id: (typeof p.id === "string" && p.id) ? p.id : "xy" + Date.now() + Math.random().toString(36).slice(2, 7),
+ name: (typeof p.name === "string" && p.name.trim()) ? p.name.slice(0, 24) : "Combo",
+ x, y, mom: !!p.mom,
+ };
+}
+function openXyPresets() {
+ $("xypreset-title").textContent = "XY PRESETS";
+ $("xypreset-note").textContent = "Templates switch the bus to that effect if it isn't already " +
+ "loaded, then assign both axes from its real parameter names. Your own combos just re-point " +
+ "X/Y to wherever they were pointed when you saved them.";
+ renderXyTemplateList();
+ renderXyCustomList();
+ $("xypreset").classList.add("open");
+}
+function renderXyTemplateList() {
+ const host = $("xypreset-templates");
+ host.textContent = "";
+ XY_TEMPLATES.forEach((t) => {
+ const b = el("button", "setup-btn", t.name);
+ b.addEventListener("click", () => openXyTemplateBusPick(t));
+ host.appendChild(b);
+ });
+}
+function openXyTemplateBusPick(t) {
+ const host = $("xypreset-templates");
+ host.textContent = "";
+ const back = el("button", "setup-btn", "‹ Back");
+ back.addEventListener("click", renderXyTemplateList);
+ host.appendChild(back);
+ xyTemplateBuses(t).forEach((i) => {
+ const b = el("button", "setup-btn", BUSES[i].label);
+ b.addEventListener("click", () => applyXyTemplate(t, i));
+ host.appendChild(b);
+ });
+ $("xypreset-note").textContent = t.note + " Pick which bus.";
+}
+function applyXyTemplate(t, bus) {
+ const table = BUSES[bus].table;
+ const fxNum = BUS_TABLES[table].indexOf(t.fx);
+ if (fxNum < 0) { showBanner("That effect isn't on this bus", "warn", 2500); return; }
+ if (state.buses[bus].fx !== fxNum) fxChange(bus, fxNum, "user");
+ const def = fxDefFor(table, fxNum);
+ const xi = def.p.findIndex((p) => p.n === t.x);
+ const yi = def.p.findIndex((p) => p.n === t.y);
+ if (xi < 0 || yi < 0) { showBanner("Couldn't find those parameters", "warn", 2500); return; }
+ state.xy.x = { bus, slot: xi };
+ state.xy.y = { bus, slot: yi };
+ saveState();
+ setActiveBus(bus);
+ renderXy();
+ $("xypreset").classList.remove("open");
+ showBanner("\"" + t.name + "\" applied to " + BUSES[bus].label, "info", 2500);
+}
+function renderXyCustomList() {
+ const host = $("xypreset-custom");
+ host.textContent = "";
+ if (!state.xyPresets.length) {
+ host.appendChild(el("p", "pick-note", "No saved combos yet — dial in an X/Y pair below, then “Save current”."));
+ return;
+ }
+ state.xyPresets.forEach((p) => {
+ const row = el("div", "xy-custom-row");
+ const nameBtn = el("button", "setup-btn xy-custom-name", p.name);
+ nameBtn.addEventListener("click", () => applyXyCustom(p));
+ const ren = el("button", "xy-custom-icon", "✎");
+ ren.addEventListener("click", (e) => { e.stopPropagation(); renameXyPreset(row, p); });
+ const del = el("button", "xy-custom-icon", "✕");
+ del.addEventListener("click", (e) => { e.stopPropagation(); deleteXyPreset(p.id); });
+ row.appendChild(nameBtn); row.appendChild(ren); row.appendChild(del);
+ host.appendChild(row);
+ });
+}
+function renameXyPreset(row, p) {
+ const nameBtn = row.querySelector(".xy-custom-name");
+ const input = el("input", "xy-custom-edit");
+ input.type = "text"; input.maxLength = 24; input.value = p.name;
+ nameBtn.replaceWith(input);
+ input.addEventListener("pointerdown", (e) => e.stopPropagation());
+ input.addEventListener("keydown", (e) => {
+ e.stopPropagation();
+ if (e.key === "Enter") input.blur();
+ if (e.key === "Escape") { input.value = p.name; input.blur(); }
+ });
+ input.addEventListener("blur", () => {
+ const v = input.value.trim();
+ if (v) p.name = v.slice(0, 24);
+ saveState();
+ renderXyCustomList();
+ });
+ setTimeout(() => { input.focus(); input.select(); }, 30);
+}
+function applyXyCustom(p) {
+ state.xy.x = { bus: p.x.bus, slot: p.x.slot };
+ state.xy.y = { bus: p.y.bus, slot: p.y.slot };
+ state.xy.mom = !!p.mom;
+ saveState();
+ renderXy();
+ $("xypreset").classList.remove("open");
+ showBanner("\"" + p.name + "\" applied", "info", 2000);
+}
+function deleteXyPreset(id) {
+ state.xyPresets = state.xyPresets.filter((p) => p.id !== id);
+ saveState();
+ renderXyCustomList();
+}
+function saveCurrentXyPreset() {
+ const ax = xyAssign("x"), ay = xyAssign("y");
+ if (!ax || !ay) { showBanner("Assign both X and Y first", "info", 2500); return; }
+ if (state.xyPresets.length >= XY_PRESETS_MAX) { showBanner("Combo list is full — delete one first", "warn", 2500); return; }
+ const p = {
+ id: "xy" + Date.now() + Math.random().toString(36).slice(2, 7),
+ name: "Combo " + (state.xyPresets.length + 1),
+ x: { bus: ax.bus, slot: ax.slot }, y: { bus: ay.bus, slot: ay.slot }, mom: !!state.xy.mom,
+ };
+ state.xyPresets.push(p);
+ saveState();
+ renderXyCustomList();
+ const row = [...$("xypreset-custom").children].find((r) => r.querySelector(".xy-custom-name")?.textContent === p.name);
+ if (row) renameXyPreset(row, p);
+}
+
let xyPickAxis = "x", xyPickBus = 0;
function openXyPick(axis) {
xyPickAxis = axis;
@@ -2946,9 +4150,550 @@ 404 Busdriver
fxChange(bus, next, "user");
}
+/* ===== 11b JAM: user-programmable pinned controls =====
+ Two independent value sources per slot:
+ - "bus": pins a real SP-404 bus/ctrl slot (same CC the currently loaded
+ effect on that bus uses) - reuses setBusCc, so it stays in sync with the
+ Focus view and hardware encoder input for that slot automatically.
+ - "raw": arbitrary channel/CC/Note/PC to any connected MIDI output,
+ independent of the SP-404 (e.g. an MC-101 on a second port). */
+function jamGet(cfg) { return cfg.source === "bus" ? state.buses[cfg.bus].cc[cfg.ctrl] : cfg.val; }
+function jamSet(cfg, v, opts) {
+ v = clamp(v | 0, 0, 127);
+ if (cfg.source === "bus") { setBusCc(cfg.bus, cfg.ctrl, v, opts); return; }
+ cfg.val = v;
+ Midi.sendToPort(cfg.port, cfg.ch, cfg.num, v);
+ saveState();
+}
+/* JAM scene pads (bus-scene recall): kept live so a backgrounded tab or a
+ rebuild mid-hold can force-release them rather than leaving a bus stuck */
+const jamSceneActive = new Set();
+function jamPadFire(cfg, on) {
+ if (cfg.source === "scene") {
+ if (on) {
+ cfg._revert = { fx: state.buses[cfg.bus].fx, cc: state.buses[cfg.bus].cc.slice(), on: state.buses[cfg.bus].on };
+ /* force EFX on regardless of what was captured - the whole point of
+ pressing this is to hear the effect; a bus that happened to be off
+ at capture time shouldn't silently stay inaudible while held */
+ applyBusState(cfg.bus, { fx: cfg.fx, cc: cfg.cc, on: true });
+ cfg._active = true;
+ jamSceneActive.add(cfg);
+ } else if (cfg._revert) {
+ /* always restores exactly what was captured on press - including the
+ effect itself, not just its values - regardless of anything tweaked
+ while held. Predictable momentary behavior: release never silently
+ rewrites the saved scene, a stray touch mid-hold can't cost you
+ a carefully-set-up scene. */
+ applyBusState(cfg.bus, cfg._revert);
+ cfg._revert = null;
+ cfg._active = false;
+ jamSceneActive.delete(cfg);
+ }
+ return;
+ }
+ if (cfg.source === "bus") { setBusCc(cfg.bus, cfg.ctrl, on ? cfg.onVal : cfg.offVal, { force: true }); return; }
+ if (cfg.type === "pc") { if (on) Midi.sendPcTo(cfg.port, cfg.ch, cfg.num); return; }
+ if (cfg.type === "note") { Midi.sendNoteTo(cfg.port, cfg.ch, cfg.num, on, cfg.vel); return; }
+ cfg.val = on ? cfg.onVal : cfg.offVal;
+ Midi.sendToPort(cfg.port, cfg.ch, cfg.num, cfg.val);
+ saveState();
+}
+function releaseAllJamScenes() {
+ [...jamSceneActive].forEach((cfg) => jamPadFire(cfg, false));
+}
+function jamLabel(cfg) {
+ if (cfg.label) return cfg.label;
+ if (cfg.source === "scene") return BUSES[cfg.bus].short + " SCENE";
+ return cfg.source === "bus" ? BUSES[cfg.bus].short + " · CTRL" + (cfg.ctrl + 1) : (cfg.type === "note" ? "N" : cfg.type === "pc" ? "PC" : "CC") + cfg.num;
+}
+function attachJamPad(btn, cfg) {
+ let down = false;
+ const isOn = () => cfg.source === "scene" ? !!cfg._active : (cfg.type !== "note" && jamGet(cfg) >= 64);
+ function paint() { if (cfg.mode === "toggle") btn.classList.toggle("on", isOn()); }
+ btn.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ btn.setPointerCapture(e.pointerId);
+ down = true;
+ if (cfg.mode === "toggle") { jamPadFire(cfg, !isOn()); paint(); }
+ else { jamPadFire(cfg, true); btn.classList.add("on"); }
+ });
+ function release(e) {
+ if (!down) return;
+ down = false;
+ if (cfg.mode !== "toggle") { jamPadFire(cfg, false); btn.classList.remove("on"); }
+ }
+ btn.addEventListener("pointerup", release);
+ btn.addEventListener("pointercancel", release);
+ paint();
+ return { update: paint };
+}
+function buildJamFaderDom(cfg) {
+ const fader = el("div", "fader");
+ const val = el("button", "fader-val display mono");
+ const trackWrap = el("div", "fader-track-wrap");
+ const track = el("div", "fader-track");
+ const fill = el("div", "fader-fill");
+ const thumb = el("div", "fader-thumb");
+ const knobWrap = el("div", "jam-knob-wrap");
+ const knobDial = el("div", "jam-knob-dial");
+ const knobPointer = el("div", "jam-knob-pointer");
+ const big = el("div", "bigval display mono");
+ track.appendChild(fill); track.appendChild(thumb);
+ trackWrap.appendChild(track);
+ knobDial.appendChild(knobPointer);
+ knobWrap.appendChild(knobDial);
+ fader.appendChild(val); fader.appendChild(trackWrap); fader.appendChild(knobWrap); fader.appendChild(big);
+ function update() {
+ const v = jamGet(cfg);
+ const pct = v / 127 * 100;
+ paintFader(fill, thumb, pct);
+ paintKnob(knobDial, knobPointer, pct);
+ val.textContent = String(v);
+ big.textContent = String(v);
+ }
+ update();
+ const echoKey = cfg.source === "bus" ? (BUSES[cfg.bus].ch << 8 | CC_CTRL[cfg.ctrl])
+ : (cfg.source === "raw" && cfg.type === "cc") ? (cfg.ch << 8 | cfg.num) : null;
+ attachFaderDrag(fader, { val, big, track }, {
+ key: "jam:" + cfg.id, echoKey, allowHoriz: false, sensitivity: 180,
+ get: () => jamGet(cfg),
+ /* bus-source repaints async via queueSlotRender -> jamByBusSlot; raw
+ source has no such hook, so paint synchronously here for both -
+ harmless extra paint for bus (RAF would've done it a moment later),
+ required for raw (nothing else ever repaints it) */
+ set: (v, o) => { jamSet(cfg, v, o); update(); },
+ onDefault: () => 64,
+ trailingSend: () => {
+ if (cfg.source === "bus") Midi.send(BUSES[cfg.bus].ch, CC_CTRL[cfg.ctrl], jamGet(cfg), true);
+ else Midi.sendToPort(cfg.port, cfg.ch, cfg.num, jamGet(cfg));
+ },
+ });
+ return { fader, update };
+}
+function registerJamUpdate(cfg, fn) {
+ if (cfg.source === "bus") {
+ const k = cfg.bus * 8 + cfg.ctrl;
+ if (!jamByBusSlot.has(k)) jamByBusSlot.set(k, new Set());
+ jamByBusSlot.get(k).add(fn);
+ } else if (cfg.source === "raw" && cfg.type === "cc") {
+ if (!jamRawUpdateFns.has(cfg.id)) jamRawUpdateFns.set(cfg.id, new Set());
+ jamRawUpdateFns.get(cfg.id).add(fn);
+ }
+}
+let jamEditing = false;
+/* select mode: mutually exclusive with edit mode so a tap on a tile is
+ never ambiguous between "toggle selection" and "start a reorder drag" */
+let jamSelectMode = false;
+const jamSelected = new Set(); /* cfg.id values */
+let jamDropIndex = null;
+function updateJamDropTarget(dragTile, x, y) {
+ const kids = [...$("jam-grid").querySelectorAll(".jam-slot")];
+ let best = null, bestDist = Infinity;
+ kids.forEach((k) => {
+ if (k === dragTile) return;
+ const r = k.getBoundingClientRect();
+ const d = Math.hypot(x - (r.left + r.width / 2), y - (r.top + r.height / 2));
+ if (d < bestDist) { bestDist = d; best = k; }
+ });
+ kids.forEach((k) => k.classList.remove("jam-drop-before"));
+ if (best) { best.classList.add("jam-drop-before"); jamDropIndex = +best.dataset.jamIndex; }
+ else jamDropIndex = null;
+}
+function consumeJamDropTarget() {
+ const v = jamDropIndex;
+ jamDropIndex = null;
+ [...$("jam-grid").querySelectorAll(".jam-slot")].forEach((k) => k.classList.remove("jam-drop-before"));
+ return v;
+}
+function attachJamEditGestures(tile, index) {
+ let startX = 0, startY = 0, moved = 0, lifting = false, pid = null, curDx = 0, curDy = 0, deleteArmed = false;
+ tile.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ tile.setPointerCapture(e.pointerId);
+ pid = e.pointerId; startX = e.clientX; startY = e.clientY; moved = 0; lifting = false; deleteArmed = false;
+ });
+ tile.addEventListener("pointermove", (e) => {
+ if (e.pointerId !== pid) return;
+ curDx = e.clientX - startX; curDy = e.clientY - startY;
+ moved = Math.max(moved, Math.abs(curDx), Math.abs(curDy));
+ if (!lifting && moved > 10) { lifting = true; tile.classList.add("jam-lift"); }
+ if (lifting) {
+ tile.style.transform = "translate(" + curDx + "px," + curDy + "px)";
+ const r = tile.getBoundingClientRect();
+ /* armed once the swipe has gone leftward past half the tile's own
+ width while staying mostly horizontal - far enough that it can't
+ be mistaken for "drag onto the tile to my left" (reorder) */
+ deleteArmed = curDx < -r.width * 0.55 && Math.abs(curDy) < r.height * 0.45;
+ tile.classList.toggle("jam-delete-hint", deleteArmed);
+ if (deleteArmed) consumeJamDropTarget();
+ else updateJamDropTarget(tile, e.clientX, e.clientY);
+ }
+ });
+ function end(e) {
+ if (e.pointerId !== pid) return;
+ pid = null;
+ if (lifting) {
+ tile.classList.remove("jam-lift", "jam-delete-hint");
+ tile.style.transform = "";
+ if (deleteArmed) { deleteJamSlotAt(index); return; }
+ const dropIdx = consumeJamDropTarget();
+ if (dropIdx != null && dropIdx !== index) {
+ /* insert at the target's ORIGINAL index (not -1'd) - removing the
+ dragged item shifts everything after it down by one, which is
+ exactly what lands it in the target's old slot either direction */
+ const [item] = state.jam.slots.splice(index, 1);
+ state.jam.slots.splice(dropIdx, 0, item);
+ saveState();
+ }
+ buildJam();
+ } else if (moved < 8) {
+ openJamEditor(index);
+ }
+ }
+ tile.addEventListener("pointerup", end);
+ tile.addEventListener("pointercancel", end);
+}
+/* single-tile delete via swipe, reusing the same undo mechanism as
+ clearJam() (the CLEAR button becomes UNDO regardless of which of the two
+ emptied the grid, or partially emptied it) */
+function deleteJamSlotAt(index) {
+ const cfg = state.jam.slots[index];
+ if (!cfg) return;
+ if (cfg.source === "scene") jamSceneActive.delete(cfg);
+ jamClearUndo = { slots: state.jam.slots.slice(), t: performance.now() };
+ state.jam.slots.splice(index, 1);
+ saveState();
+ buildJam();
+ showBanner("Removed \"" + jamLabel(cfg) + "\" – tap CLEAR to undo", "info", 3000);
+ setTimeout(updateJamClearBtn, 6100);
+}
+function buildJamTile(cfg, index) {
+ const tile = el("div", "jam-slot jam-" + cfg.kind + (jamSelected.has(cfg.id) ? " jam-selected" : ""));
+ tile.dataset.jamIndex = index;
+ if (cfg.kind === "pad") {
+ /* the pad button carries its own label - no separate slot-label needed */
+ const btn = el("button", "jam-pad-btn pad", jamLabel(cfg));
+ tile.appendChild(btn);
+ if (!jamEditing && !jamSelectMode) {
+ const api = attachJamPad(btn, cfg);
+ registerJamUpdate(cfg, api.update);
+ }
+ } else {
+ const dom = buildJamFaderDom(cfg);
+ tile.appendChild(dom.fader);
+ tile.appendChild(el("div", "slot-label", jamLabel(cfg)));
+ if (!jamEditing && !jamSelectMode) registerJamUpdate(cfg, dom.update);
+ }
+ if (jamEditing) attachJamEditGestures(tile, index);
+ if (jamSelectMode) attachJamSelectGesture(tile, cfg);
+ return tile;
+}
+/* select mode: a plain tap toggles selection - no drag/swipe to disambiguate
+ here, so it wins over the tile's own pad/fader listeners via the same
+ bubble-then-capture-override behavior attachJamEditGestures relies on */
+function attachJamSelectGesture(tile, cfg) {
+ let sx = 0, sy = 0, moved = 0, pid = null;
+ tile.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ tile.setPointerCapture(e.pointerId);
+ pid = e.pointerId; sx = e.clientX; sy = e.clientY; moved = 0;
+ });
+ tile.addEventListener("pointermove", (e) => {
+ if (e.pointerId !== pid) return;
+ moved = Math.max(moved, Math.abs(e.clientX - sx), Math.abs(e.clientY - sy));
+ });
+ function end(e) {
+ if (e.pointerId !== pid) return;
+ pid = null;
+ if (moved < 8) {
+ if (jamSelected.has(cfg.id)) jamSelected.delete(cfg.id);
+ else jamSelected.add(cfg.id);
+ buildJam();
+ }
+ }
+ tile.addEventListener("pointerup", end);
+ tile.addEventListener("pointercancel", end);
+}
+function buildJamAddTile() {
+ const btn = el("button", "jam-add", "+");
+ btn.addEventListener("pointerdown", (e) => { e.preventDefault(); openJamEditor(null); });
+ return btn;
+}
+function buildJam() {
+ const host = $("jam-grid");
+ finishDragsIn(host);
+ releaseAllJamScenes(); /* a rebuild mid-hold must not leave a bus stuck on the captured scene */
+ /* prune selections for tiles that no longer exist (deleted elsewhere) */
+ const liveIds = new Set(state.jam.slots.map((c) => c.id));
+ [...jamSelected].forEach((id) => { if (!liveIds.has(id)) jamSelected.delete(id); });
+ host.textContent = "";
+ jamByBusSlot.clear();
+ jamRawUpdateFns.clear();
+ document.body.classList.toggle("jam-editing", jamEditing);
+ document.body.classList.toggle("jam-selecting", jamSelectMode);
+ $("jam-edit-btn").classList.toggle("on", jamEditing);
+ $("jam-edit-btn").textContent = jamEditing ? "DONE" : "EDIT";
+ $("jam-select-btn").classList.toggle("on", jamSelectMode);
+ $("jam-select-btn").textContent = jamSelectMode ? "DONE" : "SELECT";
+ $("jam-batch-bar").classList.toggle("hide", !jamSelectMode);
+ $("jam-batch-count").textContent = jamSelected.size + " selected";
+ state.jam.slots.forEach((cfg, i) => host.appendChild(buildJamTile(cfg, i)));
+ if (!jamEditing && !jamSelectMode && state.jam.slots.length < JAM_MAX) host.appendChild(buildJamAddTile());
+ renderFocusScenes(); /* keep the Focus-view sidebar in sync with any add/edit/delete/reorder here */
+ updateJamClearBtn();
+}
+/* window.confirm() is unreliable on this app's actual target platform (iOS
+ Web MIDI wrapper browsers like MIDIWeb Browser often stub it to return
+ false without ever prompting - see snapSave()'s comment re: prompt() for
+ the same class of bug) so this mirrors the RND button's proven pattern
+ instead: act immediately, become UNDO for a few seconds rather than ask
+ first. Safer in practice too - a stray tap is recoverable either way. */
+let jamClearUndo = null; /* { slots, t } */
+function clearJam() {
+ if (jamClearUndo && performance.now() - jamClearUndo.t < 6000) {
+ state.jam.slots = jamClearUndo.slots;
+ jamClearUndo = null;
+ saveState();
+ buildJam();
+ showBanner("JAM restored", "info", 2000);
+ return;
+ }
+ if (!state.jam.slots.length) { showBanner("JAM is already empty", "info", 2000); return; }
+ releaseAllJamScenes();
+ jamClearUndo = { slots: state.jam.slots, t: performance.now() };
+ state.jam.slots = [];
+ saveState();
+ buildJam();
+ showBanner("JAM cleared - tap CLEAR again within 6s to undo", "info", 3000);
+ setTimeout(updateJamClearBtn, 6100);
+}
+function updateJamClearBtn() {
+ const btn = $("jam-clear-btn");
+ if (!btn) return;
+ const active = jamClearUndo && performance.now() - jamClearUndo.t < 6000;
+ btn.textContent = active ? "UNDO" : "CLEAR";
+ btn.classList.toggle("undo", !!active);
+}
+
+/* ===== JAM control editor overlay ===== */
+let jamDraft = null, jamDraftIndex = null;
+function defaultJamSlot() {
+ return {
+ id: "j" + Date.now() + Math.random().toString(36).slice(2, 7),
+ kind: "pad", source: "bus", label: "",
+ bus: state.activeBus || 0, ctrl: 0,
+ port: null, ch: 0, type: "cc", num: 1, val: 64,
+ mode: "toggle", onVal: 127, offVal: 0, vel: 110,
+ fx: 0, cc: [64, 64, 64, 64, 64, 64], on: false, /* scene source */
+ };
+}
+function openJamEditor(index) {
+ jamDraftIndex = index;
+ jamDraft = index == null ? defaultJamSlot() : Object.assign({}, state.jam.slots[index]);
+ /* an existing scene slot already has valid captured data (fx/cc/on were
+ sanitized in) - _captured is a draft-only flag, never persisted, so it
+ must be re-derived here or re-editing one would look "not captured yet" */
+ if (jamDraft.source === "scene") jamDraft._captured = true;
+ renderJamEditor();
+ $("jamedit").classList.add("open");
+}
+/* Focus view SAVE button: one tap from wherever you're dialing a sound in,
+ rather than the multi-step "go to JAM > + > pick Bus scene > pick bus >
+ Capture" - pre-fills a scene draft already captured from the active bus
+ and opens the same editor so a label/mode still has to be confirmed. */
+function quickSaveBusScene() {
+ const bus = state.activeBus, b = state.buses[bus];
+ jamDraftIndex = null;
+ jamDraft = defaultJamSlot();
+ jamDraft.source = "scene";
+ jamDraft.bus = bus;
+ jamDraft.fx = b.fx;
+ jamDraft.cc = b.cc.slice();
+ jamDraft.on = b.on;
+ jamDraft._captured = true;
+ jamDraft.mode = "momentary";
+ jamDraft.label = BUSES[bus].short + " " + (b.fx > 0 ? fxDisplayName(BUSES[bus].table, b.fx) : "SCENE");
+ renderJamEditor();
+ $("jamedit").classList.add("open");
+}
+function jamOptRow(hostId, options, isSel, onPick) {
+ const host = $(hostId);
+ host.textContent = "";
+ options.forEach((opt) => {
+ const b = el("button", "setup-btn" + (isSel(opt.v) ? " sel" : ""), opt.t);
+ b.addEventListener("click", () => onPick(opt.v));
+ host.appendChild(b);
+ });
+}
+function renderJamEditor() {
+ const d = jamDraft;
+ $("jamedit-title").textContent = jamDraftIndex == null ? "NEW CONTROL" : "EDIT CONTROL";
+ jamOptRow("je-kind", [{ v: "pad", t: "Pad" }, { v: "fader", t: "Fader" }], (v) => v === d.kind, (v) => {
+ if (d.source === "scene" && v === "fader") return; /* scenes can't be a fader */
+ d.kind = v;
+ if (v === "fader") d.type = "cc";
+ renderJamEditor();
+ });
+ jamOptRow("je-source", [{ v: "bus", t: "SP-404 bus param" }, { v: "raw", t: "Raw MIDI" }, { v: "scene", t: "Bus scene" }], (v) => v === d.source, (v) => {
+ d.source = v;
+ if (v === "scene") d.kind = "pad"; /* a captured scene can't be dragged like a single CC */
+ renderJamEditor();
+ });
+ $("je-bus-fields").classList.toggle("hide", d.source !== "bus");
+ $("je-raw-fields").classList.toggle("hide", d.source !== "raw");
+ $("je-scene-fields").classList.toggle("hide", d.source !== "scene");
+ $("je-pad-fields").classList.toggle("hide", d.kind !== "pad");
+ if (d.source === "bus") {
+ jamOptRow("je-bus", BUSES.map((B, i) => ({ v: i, t: B.label })), (v) => v === d.bus, (v) => { d.bus = v; renderJamEditor(); });
+ jamOptRow("je-ctrl", [0, 1, 2, 3, 4, 5].map((i) => ({ v: i, t: "CTRL " + (i + 1) })), (v) => v === d.ctrl, (v) => { d.ctrl = v; renderJamEditor(); });
+ } else if (d.source === "scene") {
+ jamOptRow("je-scene-bus", BUSES.map((B, i) => ({ v: i, t: B.label })), (v) => v === d.bus, (v) => { d.bus = v; renderJamEditor(); });
+ const B = BUSES[d.bus];
+ $("je-scene-status").textContent = d._captured
+ ? "Captured: " + fxDisplayName(B.table, d.fx) + " on " + B.label + (d.on ? " (on)" : " (off)")
+ : "Not captured yet – dial in the sound you want live, then tap Capture.";
+ } else {
+ const outs = [];
+ if (Midi.access) Midi.access.outputs.forEach((p) => { if (Midi.live(p)) outs.push(p.name); });
+ const portOpts = [{ v: null, t: "Default (SP-404 output)" }].concat(outs.map((n) => ({ v: n, t: n })));
+ jamOptRow("je-port", portOpts, (v) => v === d.port, (v) => { d.port = v; renderJamEditor(); });
+ const typeOpts = d.kind === "fader" ? [{ v: "cc", t: "CC" }] : [{ v: "cc", t: "CC" }, { v: "note", t: "Note" }, { v: "pc", t: "Program Change" }];
+ jamOptRow("je-type", typeOpts, (v) => v === d.type, (v) => { d.type = v; if (v !== "cc") d.mode = "momentary"; renderJamEditor(); });
+ $("je-ch").value = d.ch + 1;
+ $("je-num-label").textContent = d.type === "note" ? "Note #" : d.type === "pc" ? "Pattern/PC #" : "CC #";
+ $("je-num").value = d.num;
+ }
+ if (d.kind === "pad") {
+ const modeOpts = (d.source === "bus" || d.source === "scene" || d.type === "cc") ? [{ v: "toggle", t: "Toggle" }, { v: "momentary", t: "Momentary" }] : [{ v: "momentary", t: "Momentary (fixed)" }];
+ jamOptRow("je-mode", modeOpts, (v) => v === d.mode, (v) => { d.mode = v; renderJamEditor(); });
+ const ccLike = d.source === "bus" || d.type === "cc";
+ $("je-onval-field").classList.toggle("hide", !ccLike);
+ $("je-offval-field").classList.toggle("hide", !ccLike);
+ $("je-vel-field").classList.toggle("hide", !(d.source === "raw" && d.type === "note"));
+ $("je-onval").value = d.onVal;
+ $("je-offval").value = d.offVal;
+ $("je-vel").value = d.vel;
+ }
+ $("je-label").value = d.label || "";
+ $("je-delete").style.display = jamDraftIndex == null ? "none" : "";
+}
+function readJamEditorInputs() {
+ const d = jamDraft;
+ if (d.source === "raw") {
+ d.ch = clamp((+$("je-ch").value || 1) - 1, 0, 15);
+ d.num = clamp(+$("je-num").value || 0, 0, 127);
+ }
+ if (d.kind === "pad") {
+ d.onVal = clamp(+$("je-onval").value, 0, 127);
+ d.offVal = clamp(+$("je-offval").value, 0, 127);
+ d.vel = clamp(+$("je-vel").value || 110, 1, 127);
+ }
+ d.label = $("je-label").value.trim().slice(0, 16);
+}
+function saveJamEditor() {
+ readJamEditorInputs();
+ if (jamDraft.source === "scene" && !jamDraft._captured) {
+ showBanner("Capture a bus state first", "warn", 2500);
+ return;
+ }
+ const clean = sanitizeJamSlot(jamDraft);
+ if (!clean) return;
+ if (jamDraftIndex == null) state.jam.slots.push(clean);
+ else state.jam.slots[jamDraftIndex] = clean;
+ saveState();
+ $("jamedit").classList.remove("open");
+ buildJam();
+}
+function deleteJamEditor() {
+ if (jamDraftIndex != null) {
+ state.jam.slots.splice(jamDraftIndex, 1);
+ saveState();
+ }
+ $("jamedit").classList.remove("open");
+ buildJam();
+}
+
+/* ===== JAM presets: factory templates for common gear, from verified
+ MIDI implementation charts (not guessed) - each control still gets its
+ own editable channel/CC/label after adding, so a wrong assumption here
+ (e.g. this device's default per-track channel, which is user-configurable
+ on the MC-101 itself) is a one-tap fix, not a dead end. ===== */
+const JAM_PRESETS = [
+ {
+ id: "mc101-knobs",
+ name: "MC-101 — 4 tracks × Sound/Filter/Mod/FX",
+ note: "CC 80–83 (SOUND/FILTER/MOD/FX knobs, in the device's own left-to-right " +
+ "order) per track, from Roland's MC-101 " +
+ "MIDI implementation chart — both transmit and receive, so these mirror the " +
+ "hardware knobs live. Track channels default to 1–4; check yours against " +
+ "SHIFT + TRACK SEL on the device if it differs.",
+ build() {
+ /* physical knob order on the MC-101 itself: CTRL1 SOUND, CTRL2 FILTER,
+ CTRL3 MOD, CTRL4 FX - matches left-to-right so the JAM grid mirrors
+ the hardware layout */
+ const knobs = [["SOUND", 83], ["FILTER", 80], ["MOD", 81], ["FX", 82]];
+ const slots = [];
+ for (let t = 0; t < 4; t++) {
+ knobs.forEach(([label, num]) => {
+ slots.push({ kind: "fader", source: "raw", ch: t, type: "cc", num, label: "T" + (t + 1) + " " + label, val: 64 });
+ });
+ }
+ return slots;
+ },
+ },
+];
+let jamPresetPending = null;
+function openJamPresets() {
+ jamPresetPending = null;
+ $("jampreset-title").textContent = "JAM PRESETS";
+ $("jampreset-note").textContent = "Adds the preset's controls to your JAM grid – " +
+ "existing controls are kept. Every added control keeps its own editable " +
+ "channel/CC/label afterward, same as anything else in JAM.";
+ const host = $("jampreset-list");
+ host.textContent = "";
+ JAM_PRESETS.forEach((preset) => {
+ const b = el("button", "setup-btn", preset.name);
+ b.addEventListener("click", () => openJamPresetPortPick(preset));
+ host.appendChild(b);
+ });
+ $("jampreset").classList.add("open");
+}
+function openJamPresetPortPick(preset) {
+ jamPresetPending = preset;
+ $("jampreset-title").textContent = preset.name;
+ $("jampreset-note").textContent = preset.note + " Pick which connected output this preset's controls should target.";
+ const host = $("jampreset-list");
+ host.textContent = "";
+ const back = el("button", "setup-btn", "‹ Back");
+ back.addEventListener("click", openJamPresets);
+ host.appendChild(back);
+ const mkPort = (name, label) => {
+ const b = el("button", "setup-btn", label);
+ b.addEventListener("click", () => applyJamPreset(preset, name));
+ host.appendChild(b);
+ };
+ mkPort(null, "Default (SP-404 output)");
+ if (Midi.access) Midi.access.outputs.forEach((p) => { if (Midi.live(p)) mkPort(p.name, p.name); });
+}
+function applyJamPreset(preset, port) {
+ const room = JAM_MAX - state.jam.slots.length;
+ if (room <= 0) { showBanner("JAM grid is full – delete some controls first", "warn", 3000); return; }
+ const built = preset.build().map((c) => Object.assign({ port }, c));
+ const clean = built.slice(0, room).map(sanitizeJamSlot).filter(Boolean);
+ state.jam.slots.push(...clean);
+ saveState();
+ $("jampreset").classList.remove("open");
+ buildJam();
+ const dropped = built.length - clean.length;
+ showBanner("Added " + clean.length + " controls from \"" + preset.name + "\"" + (dropped ? " (" + dropped + " skipped, grid full)" : ""), "info", 3000);
+}
+
/* ===== 12 BOOT ===== */
function boot() {
loadState();
+ document.body.classList.toggle("horiz-faders", state.horizFaders);
+ document.body.classList.toggle("jam-knobs", state.jamKnobs);
+ applyTheme();
buildTabs();
buildOverview();
BUSES.forEach((_, i) => renderBus(i));
@@ -2965,6 +4710,18 @@ 404 Busdriver
$("xy-xa").addEventListener("click", () => openXyPick("x"));
$("xy-ya").addEventListener("click", () => openXyPick("y"));
$("xy-mom").addEventListener("click", () => { state.xy.mom = !state.xy.mom; saveState(); renderXy(); });
+ $("xy-presets-btn").addEventListener("click", openXyPresets);
+ $("xypreset-close").addEventListener("click", () => $("xypreset").classList.remove("open"));
+ $("xypreset").addEventListener("pointerdown", (e) => { if (e.target === $("xypreset")) $("xypreset").classList.remove("open"); });
+ $("xypreset-save").addEventListener("click", saveCurrentXyPreset);
+ $("recipes-btn").addEventListener("click", openRecipes);
+ $("lib-btn").addEventListener("click", openRecipes);
+ $("recipes-close").addEventListener("click", () => $("recipes").classList.remove("open"));
+ $("recipes").addEventListener("pointerdown", (e) => { if (e.target === $("recipes")) $("recipes").classList.remove("open"); });
+ $("recipe-new-btn").addEventListener("click", () => openRecipeEditor(null));
+ $("recipe-edit-close").addEventListener("click", () => $("recipe-edit").classList.remove("open"));
+ $("recipe-edit").addEventListener("pointerdown", (e) => { if (e.target === $("recipe-edit")) $("recipe-edit").classList.remove("open"); });
+ $("re-save").addEventListener("click", saveRecipeDraft);
$("xypick-close").addEventListener("click", () => $("xypick").classList.remove("open"));
$("xypick").addEventListener("pointerdown", (e) => { if (e.target === $("xypick")) $("xypick").classList.remove("open"); });
$("xypick-none").addEventListener("click", () => {
@@ -3022,6 +4779,58 @@ 404 Busdriver
$("padpick").classList.remove("open");
buildPads();
});
+ /* jam tab */
+ $("jam-edit-btn").addEventListener("click", () => {
+ jamEditing = !jamEditing;
+ if (jamEditing) { jamSelectMode = false; jamSelected.clear(); }
+ buildJam();
+ });
+ $("jam-select-btn").addEventListener("click", () => {
+ jamSelectMode = !jamSelectMode;
+ jamSelected.clear();
+ if (jamSelectMode) jamEditing = false;
+ buildJam();
+ });
+ $("jam-batch-apply-ch").addEventListener("click", () => {
+ if (!jamSelected.size) { showBanner("Select some controls first", "info", 2000); return; }
+ const ch = clamp((+$("jam-batch-ch").value || 1) - 1, 0, 15);
+ let n = 0;
+ state.jam.slots.forEach((cfg) => {
+ if (jamSelected.has(cfg.id) && cfg.source === "raw") { cfg.ch = ch; n++; }
+ });
+ saveState();
+ buildJam();
+ showBanner(n
+ ? "Channel " + (ch + 1) + " applied to " + n + " control(s)"
+ : "None of the selected controls use a MIDI channel (bus/scene sources don't)", "info", 3000);
+ });
+ $("jam-batch-delete").addEventListener("click", () => {
+ if (!jamSelected.size) { showBanner("Select some controls first", "info", 2000); return; }
+ jamClearUndo = { slots: state.jam.slots.slice(), t: performance.now() };
+ const removed = state.jam.slots.filter((c) => jamSelected.has(c.id));
+ removed.forEach((cfg) => { if (cfg.source === "scene") jamSceneActive.delete(cfg); });
+ state.jam.slots = state.jam.slots.filter((c) => !jamSelected.has(c.id));
+ jamSelected.clear();
+ jamSelectMode = false;
+ saveState();
+ buildJam();
+ showBanner(removed.length + " control(s) removed – tap CLEAR to undo", "info", 3000);
+ setTimeout(updateJamClearBtn, 6100);
+ });
+ $("jamedit-close").addEventListener("click", () => $("jamedit").classList.remove("open"));
+ $("jamedit").addEventListener("pointerdown", (e) => { if (e.target === $("jamedit")) $("jamedit").classList.remove("open"); });
+ $("je-save").addEventListener("click", saveJamEditor);
+ $("je-delete").addEventListener("click", deleteJamEditor);
+ $("je-capture").addEventListener("click", () => {
+ const d = jamDraft, b = state.buses[d.bus];
+ d.fx = b.fx; d.cc = b.cc.slice(); d.on = b.on; d._captured = true;
+ renderJamEditor();
+ showBanner("Captured", "info", 1500);
+ });
+ $("jam-preset-btn").addEventListener("click", openJamPresets);
+ $("jampreset-close").addEventListener("click", () => $("jampreset").classList.remove("open"));
+ $("jampreset").addEventListener("pointerdown", (e) => { if (e.target === $("jampreset")) $("jampreset").classList.remove("open"); });
+ $("jam-clear-btn").addEventListener("click", clearJam);
/* looper (device in looper mode, CH 1) */
let lpRec = false;
$("lp-rec").addEventListener("click", () => { lpRec = !lpRec; Midi.send(0, 88, lpRec ? 127 : 0, true); $("lp-rec").classList.toggle("on", lpRec); });
@@ -3043,6 +4852,7 @@ 404 Busdriver
$("exp-btn").addEventListener("click", doExport);
$("imp-btn").addEventListener("click", doImport);
$("rnd-btn").addEventListener("click", () => randomize(state.activeBus));
+ $("bus-save-btn").addEventListener("click", quickSaveBusScene);
$("setup-btn").addEventListener("click", openSetup);
$("conn").addEventListener("click", openSetup);
$("banner").addEventListener("click", () => {
@@ -3058,6 +4868,21 @@ 404 Busdriver
$("midi-rescan").addEventListener("click", () => Midi.connect(true));
$("push-state").addEventListener("click", pushStateToDevice);
$("wake-toggle").addEventListener("click", () => { state.wake = !state.wake; saveState(); applyWake(); renderSetup(); });
+ $("horiz-toggle").addEventListener("click", () => {
+ state.horizFaders = !state.horizFaders;
+ document.body.classList.toggle("horiz-faders", state.horizFaders);
+ saveState(); renderSetup();
+ });
+ $("jam-knob-toggle").addEventListener("click", () => {
+ state.jamKnobs = !state.jamKnobs;
+ document.body.classList.toggle("jam-knobs", state.jamKnobs);
+ saveState(); renderSetup();
+ });
+ $("theme-toggle").addEventListener("click", () => {
+ state.theme = state.theme === "light" ? "dark" : "light";
+ applyTheme();
+ saveState(); renderSetup();
+ });
$("help-btn").addEventListener("click", () => $("help").classList.add("open"));
$("help-close").addEventListener("click", () => $("help").classList.remove("open"));
$("help").addEventListener("pointerdown", (e) => { if (e.target === $("help")) $("help").classList.remove("open"); });
@@ -3068,7 +4893,7 @@ 404 Busdriver
}, true);
document.addEventListener("keydown", (e) => {
- if (e.key === "Escape") { closePicker(); $("setup").classList.remove("open"); $("snaps").classList.remove("open"); $("xypick").classList.remove("open"); $("padpick").classList.remove("open"); $("help").classList.remove("open"); closeStepper(); return; }
+ if (e.key === "Escape") { closePicker(); $("setup").classList.remove("open"); $("snaps").classList.remove("open"); $("xypick").classList.remove("open"); $("padpick").classList.remove("open"); $("jamedit").classList.remove("open"); $("jampreset").classList.remove("open"); $("xypreset").classList.remove("open"); $("recipes").classList.remove("open"); $("recipe-edit").classList.remove("open"); $("help").classList.remove("open"); closeStepper(); return; }
if (e.target.tagName === "INPUT") return;
if (e.key >= "1" && e.key <= "5") gotoFocus(+e.key - 1);
else if (e.key === " ") { e.preventDefault(); const b = state.activeBus; sendSw(b, !state.buses[b].on); }
@@ -3088,6 +4913,7 @@ 404 Busdriver
flushState(); clearTimeout(syncTimer); pushSync();
flushNotesIn(null); /* no stuck notes */
flushBend();
+ releaseAllJamScenes(); /* a backgrounded tab must not leave a bus stuck mid-hold */
}
});
window.addEventListener("pagehide", () => { flushState(); clearTimeout(syncTimer); pushSync(); });