Phase 1: wrap the reference web app in Capacitor for Android
Wraps reference/sp404-layers as a sideloadable APK without editing the reference, which stays the frozen spec for the Phase 2 React port. - sync-www.sh builds www/ from reference/ + native/, asserting each patch anchor so a reference change fails loudly rather than shipping broken - drops the service worker (would serve a stale app after an APK update) - haptics via a navigator.vibrate override, so buzz() needs no change - localStorage mirrored into Preferences; sync load()/save() untouched - hardware back: sheet -> Explore tab -> exit - edge-to-edge handled in MainActivity: Android 16 + targetSdk 36 removes the opt-out, and a WebView never fills env(safe-area-inset-*), so insets are pushed into CSS custom properties instead Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
12
reference/README.md
Normal file
12
reference/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# SP-404MK2 layers (PWA)
|
||||
|
||||
Static files only. Serve this folder over HTTPS (required for install and offline).
|
||||
|
||||
Quick options:
|
||||
- Nginx container behind Nginx Proxy Manager: mount this folder at /usr/share/nginx/html.
|
||||
- `docker run -d --name 404layers -p 8404:80 -v $PWD:/usr/share/nginx/html:ro nginx:alpine`, then add a proxy host in NPM with a Let's Encrypt cert.
|
||||
- Any static host (Cloudflare Pages, GitHub Pages).
|
||||
|
||||
On the phone: open the URL in Chrome, menu, "Install app" / "Add to Home screen".
|
||||
|
||||
Updating: edit files, bump `V` in sw.js, redeploy. The page is fetched network-first, so it updates on next open when online.
|
||||
BIN
reference/fonts/barlow-400.woff2
Normal file
BIN
reference/fonts/barlow-400.woff2
Normal file
Binary file not shown.
BIN
reference/fonts/barlow-500.woff2
Normal file
BIN
reference/fonts/barlow-500.woff2
Normal file
Binary file not shown.
BIN
reference/fonts/barlow-600.woff2
Normal file
BIN
reference/fonts/barlow-600.woff2
Normal file
Binary file not shown.
BIN
reference/fonts/barlow-condensed-500.woff2
Normal file
BIN
reference/fonts/barlow-condensed-500.woff2
Normal file
Binary file not shown.
BIN
reference/fonts/barlow-condensed-600.woff2
Normal file
BIN
reference/fonts/barlow-condensed-600.woff2
Normal file
Binary file not shown.
BIN
reference/fonts/barlow-condensed-700.woff2
Normal file
BIN
reference/fonts/barlow-condensed-700.woff2
Normal file
Binary file not shown.
BIN
reference/icons/icon-192.png
Normal file
BIN
reference/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
reference/icons/icon-512.png
Normal file
BIN
reference/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
reference/icons/maskable-512.png
Normal file
BIN
reference/icons/maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
676
reference/index.html
Normal file
676
reference/index.html
Normal file
@@ -0,0 +1,676 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#2a2d30">
|
||||
<title>SP-404MK2 layers</title>
|
||||
<link rel="manifest" href="manifest.webmanifest"><link rel="icon" href="icons/icon-192.png"><link rel="apple-touch-icon" href="icons/icon-192.png"><meta name="mobile-web-app-capable" content="yes">
|
||||
<style>@font-face{font-family:"Barlow";font-weight:400;font-style:normal;font-display:swap;src:url(fonts/barlow-400.woff2) format("woff2")}@font-face{font-family:"Barlow";font-weight:500;font-style:normal;font-display:swap;src:url(fonts/barlow-500.woff2) format("woff2")}@font-face{font-family:"Barlow";font-weight:600;font-style:normal;font-display:swap;src:url(fonts/barlow-600.woff2) format("woff2")}@font-face{font-family:"Barlow Condensed";font-weight:500;font-style:normal;font-display:swap;src:url(fonts/barlow-condensed-500.woff2) format("woff2")}@font-face{font-family:"Barlow Condensed";font-weight:600;font-style:normal;font-display:swap;src:url(fonts/barlow-condensed-600.woff2) format("woff2")}@font-face{font-family:"Barlow Condensed";font-weight:700;font-style:normal;font-display:swap;src:url(fonts/barlow-condensed-700.woff2) format("woff2")}</style>
|
||||
<style>
|
||||
:root{
|
||||
--page:#dcdfe2; --ink:#1d2124; --muted:#5d656c; --line:#b9bfc5; --card:#eceef0; --sheet:#f4f5f6;
|
||||
--body:#2a2d30; --key:#43474b; --key-hi:#50555a; --keyink:#c3c8cc; --pad:#3a3e42;
|
||||
--lit:#ff7a1a; --lit-soft:rgba(255,122,26,.22); --held:#ffd08a; --ok:#2fb86a; --bad:#e0483d;
|
||||
--lcd:#06080a; --lcdink:#e4eef2;
|
||||
--f:"Barlow",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
--fc:"Barlow Condensed","Roboto Condensed","Arial Narrow",system-ui,sans-serif;
|
||||
--nav:58px; --sab:env(safe-area-inset-bottom,0px);
|
||||
}
|
||||
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
|
||||
--page:#151719; --ink:#e3e6e9; --muted:#8f979e; --line:#30353a; --card:#1d2023; --sheet:#1f2225; --body:#26292c; --key:#3d4145; --key-hi:#4a4f54; --pad:#34383c;
|
||||
}}
|
||||
:root[data-theme="dark"]{--page:#151719; --ink:#e3e6e9; --muted:#8f979e; --line:#30353a; --card:#1d2023; --sheet:#1f2225; --body:#26292c; --key:#3d4145; --key-hi:#4a4f54; --pad:#34383c;}
|
||||
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||
html,body{overscroll-behavior:none}
|
||||
body{margin:0;background:var(--page);color:var(--ink);font-family:var(--f);font-size:16px;line-height:1.45;touch-action:manipulation}
|
||||
button{font:inherit;color:inherit}
|
||||
:focus-visible{outline:2px solid var(--lit);outline-offset:2px}
|
||||
[hidden]{display:none!important}
|
||||
.muted{color:var(--muted)}
|
||||
.cap{display:inline-block;font-family:var(--fc);font-weight:600;font-size:.82rem;background:var(--key);color:#e8ebee;border-radius:5px;padding:1px 7px;margin:1px 0;white-space:nowrap}
|
||||
.plus{color:var(--muted);margin:0 3px;font-size:.8rem}
|
||||
.btn{font-family:var(--fc);font-weight:600;font-size:1.05rem;background:var(--ink);color:var(--page);border:0;border-radius:8px;padding:9px 16px;cursor:pointer;min-height:42px}
|
||||
.btn.sm{font-size:.95rem;min-height:36px;padding:5px 12px}
|
||||
.ihead{display:flex;gap:8px;align-items:flex-start;justify-content:space-between}
|
||||
.ihead>div:first-child{min-width:0}
|
||||
.ihead .ihr{display:flex;gap:6px;flex:0 0 auto;align-items:center}
|
||||
.btn.ghost{background:none;color:var(--ink);box-shadow:inset 0 0 0 1px var(--line)}
|
||||
.btn:disabled{opacity:.4}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.chip{font-size:.9rem;border:1px solid var(--line);background:var(--card);border-radius:999px;padding:6px 12px;cursor:pointer;white-space:nowrap;min-height:34px}
|
||||
.chip[aria-pressed="true"]{background:var(--ink);color:var(--page);border-color:var(--ink)}
|
||||
.chip small{opacity:.6;margin-left:3px}
|
||||
.lbl{font-size:.82rem;color:var(--muted);margin:12px 0 6px}
|
||||
|
||||
/* ---------- layout: desktop ---------- */
|
||||
.app{max-width:1080px;margin:0 auto;padding:20px 16px 40px;display:grid;grid-template-columns:minmax(300px,400px) 1fr;column-gap:28px;
|
||||
grid-template-areas:"head head" "unit tabs" "unit strip" "unit info" "unit pane";grid-template-rows:auto auto auto auto 1fr;align-items:start}
|
||||
.head{grid-area:head;margin-bottom:12px}
|
||||
.head h1{font-family:var(--fc);font-weight:700;font-size:2rem;line-height:1;margin:0 0 4px}
|
||||
.head p{margin:0;color:var(--muted);font-size:.95rem}
|
||||
#unitwrap{grid-area:unit;position:sticky;top:12px}
|
||||
.tabs{grid-area:tabs;display:flex;gap:4px;border-bottom:1px solid var(--line);margin-bottom:12px}
|
||||
.tab{font-family:var(--fc);font-size:1.1rem;font-weight:600;background:none;border:0;color:var(--muted);padding:6px 12px 8px;cursor:pointer;border-bottom:3px solid transparent;margin-bottom:-1px}
|
||||
.tab[aria-selected="true"]{color:var(--ink);border-color:var(--lit)}
|
||||
#strip{grid-area:strip}
|
||||
#info{grid-area:info;background:var(--card);border-radius:10px;padding:12px 14px;margin-top:12px}
|
||||
#pane{grid-area:pane;margin-top:10px}
|
||||
.bnav{display:none}
|
||||
body[data-tab="find"] #info,body[data-tab="rules"] #info{display:none}
|
||||
body[data-tab="drill"] #pane{display:none}
|
||||
.onlym{display:none}
|
||||
|
||||
/* ---------- the unit ---------- */
|
||||
.unit{background:var(--body);border-radius:18px;padding:12px;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;touch-action:none;display:flex;flex-direction:column;gap:9px}
|
||||
.row{display:flex;gap:6px}
|
||||
.k{font-family:var(--fc);font-weight:600;color:var(--keyink);background:var(--key);border:0;border-radius:6px;font-size:10px;line-height:1.05;padding:3px 2px;min-height:32px;flex:1;min-width:0;cursor:pointer;display:flex;align-items:center;justify-content:center;text-align:center;position:relative;overflow:hidden;letter-spacing:.02em;transition:background .1s,color .1s,box-shadow .1s,transform .06s}
|
||||
@media (hover:hover){.k:not(.target):not(.held):not(.chord):not(.good):hover{background:var(--key-hi)}}
|
||||
.k.down{transform:scale(.95)}
|
||||
.k.round{border-radius:50%;aspect-ratio:1;min-height:0;flex:0 0 auto}
|
||||
.knobs{justify-content:space-between;padding:0 6px}
|
||||
.knobs .k{width:46px;background:#1b1d1f;box-shadow:inset 0 0 0 3px #34383b}
|
||||
.fx{display:grid;grid-template-columns:76px 1fr 76px;gap:8px}
|
||||
.fx .col{display:flex;flex-direction:column;gap:6px}
|
||||
.fx .k{min-height:34px;border-radius:14px}
|
||||
.lcd{background:var(--lcd);border-radius:50%/42%;color:var(--lcdink);display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:6px 10px;font-family:var(--fc);min-height:0;overflow:hidden}
|
||||
.lcd .l1{font-size:11px;opacity:.7;letter-spacing:.06em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
||||
.lcd .l2{font-size:21px;font-weight:700;line-height:1.05;margin:2px 0}
|
||||
.lcd .l3{font-size:11px;opacity:.75;max-width:160px;line-height:1.15}
|
||||
.ctl{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,5fr);gap:10px}
|
||||
.blk{display:flex;flex-direction:column;gap:6px}
|
||||
.ctl .k{font-size:9.5px;letter-spacing:0;min-height:34px}
|
||||
.valk{width:36px;background:#1b1d1f!important;box-shadow:inset 0 0 0 3px #34383b;font-size:8px!important}
|
||||
.bank{gap:4px}.bank .k{min-height:28px!important;font-size:9px}
|
||||
.pads{display:grid;grid-template-columns:repeat(4,1fr) .9fr;grid-auto-rows:minmax(52px,1fr);gap:6px}
|
||||
.pads .k{background:var(--pad);font-size:11px;min-height:0}
|
||||
.pads .k.side{font-size:10px}
|
||||
.pads .k .n{font-size:17px;font-weight:700;color:#7b8288}
|
||||
.k.target{background:#5a3a22;color:#ffe3cc;box-shadow:0 0 0 1px var(--lit) inset,0 0 12px var(--lit-soft)}
|
||||
.pads .k.target .n{display:none}
|
||||
.k.target.sel{background:var(--lit);color:#1a0d02}
|
||||
.k.held{background:var(--lit);color:#1a0d02;box-shadow:0 0 14px var(--lit-soft)}
|
||||
.k.chord{background:var(--held);color:#231400}
|
||||
.k.good{background:var(--ok);color:#04160b}
|
||||
.k.miss{box-shadow:0 0 0 2px var(--bad) inset}
|
||||
.k .cnt{position:absolute;top:2px;right:3px;font-size:8px;opacity:.8}
|
||||
|
||||
/* ---------- info / pane content ---------- */
|
||||
.ititle{font-family:var(--fc);font-size:1.35rem;font-weight:700;line-height:1.1;margin:4px 0 2px}
|
||||
.idesc{margin:2px 0}
|
||||
.irow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}
|
||||
.fb{font-weight:600}.fb.ok{color:var(--ok)}.fb.no{color:var(--bad)}
|
||||
.opts{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:8px}
|
||||
.opt{text-align:left;font-size:.9rem;line-height:1.2;background:var(--page);border:1px solid var(--line);border-radius:8px;padding:8px 10px;min-height:44px;cursor:pointer}
|
||||
.list{border-top:1px solid var(--line)}
|
||||
.item{display:grid;grid-template-columns:minmax(120px,190px) 1fr auto;gap:10px;align-items:baseline;padding:10px 4px;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:none;width:100%;text-align:left}
|
||||
.item.on{background:var(--card);box-shadow:inset 3px 0 0 var(--lit)}
|
||||
.item .nm{font-weight:600}
|
||||
.item .pg{color:var(--muted);font-size:.8rem}
|
||||
@media (max-width:520px){.item{grid-template-columns:1fr auto}.item .combo{grid-column:1/-1;order:3}}
|
||||
input[type=search],textarea{width:100%;font:inherit;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink)}
|
||||
textarea{font-family:ui-monospace,monospace;font-size:.75rem;height:90px}
|
||||
.stripbtn{display:flex;align-items:center;gap:10px;width:100%;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px 12px;cursor:pointer;text-align:left;min-height:48px}
|
||||
.stripbtn b{font-family:var(--fc);font-size:1.05rem}
|
||||
.mini{display:flex;gap:2px;align-items:flex-end;height:22px;width:70px;flex:0 0 auto}
|
||||
.mini div{flex:1;background:var(--line);border-radius:2px 2px 0 0;min-height:2px}
|
||||
.mini div:last-child{background:var(--lit)}
|
||||
.rules{display:grid;gap:14px}
|
||||
.rule h3{font-family:var(--fc);font-size:1.25rem;margin:0 0 2px;line-height:1.15}
|
||||
.rule p{margin:0;max-width:68ch}
|
||||
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:6px;max-width:420px}
|
||||
.grid4 div{background:var(--card);border-radius:5px;padding:5px 6px;font-size:.8rem;line-height:1.15}
|
||||
.grid4 b{display:block;font-family:var(--fc);font-size:.95rem}
|
||||
.rowlbl{grid-column:1/-1;font-size:.78rem;color:var(--muted);margin-top:4px}
|
||||
|
||||
/* ---------- sheet ---------- */
|
||||
.scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:20;opacity:0;pointer-events:none;transition:opacity .15s}
|
||||
.sheet{position:fixed;left:50%;bottom:0;transform:translate(-50%,105%);width:min(640px,100%);max-height:82dvh;background:var(--sheet);border-radius:16px 16px 0 0;z-index:21;display:flex;flex-direction:column;transition:transform .18s ease-out;padding-bottom:var(--sab)}
|
||||
.sheet.open{transform:translate(-50%,0)}
|
||||
.scrim.open{opacity:1;pointer-events:auto}
|
||||
.shead{display:flex;align-items:center;justify-content:space-between;padding:10px 12px 6px 16px;border-bottom:1px solid var(--line)}
|
||||
.shead b{font-family:var(--fc);font-size:1.25rem}
|
||||
.sclose{background:none;border:0;font-size:1.6rem;line-height:1;width:44px;height:44px;cursor:pointer}
|
||||
.sbody{overflow:auto;padding:4px 16px 16px;-webkit-overflow-scrolling:touch}
|
||||
|
||||
/* ---------- layout: phone ---------- */
|
||||
@media (max-width:820px){
|
||||
.app{display:flex;flex-direction:column;align-items:stretch;height:100dvh;padding:8px 8px calc(var(--nav) + var(--sab) + 8px);gap:8px}
|
||||
body[data-tab="find"] .app,body[data-tab="rules"] .app{height:auto;min-height:100dvh}
|
||||
.app>*{min-width:0;max-width:100%}
|
||||
.head,.tabs{display:none}
|
||||
.onlym{display:initial}
|
||||
.onlyd{display:none!important}
|
||||
#unitwrap{position:static;flex:1 1 auto;display:flex;flex-direction:column;min-height:0}
|
||||
.unit{flex:1 1 auto;min-height:0;padding:8px;gap:7px;border-radius:16px;overflow:hidden}
|
||||
.knobs .k{width:36px}
|
||||
.fx{grid-template-columns:64px 1fr 64px;gap:6px}
|
||||
.fx .col{gap:5px}
|
||||
.fx .k{min-height:30px;font-size:9px}
|
||||
.lcd .l2{font-size:19px}
|
||||
.ctl{gap:6px}
|
||||
.blk{gap:5px}.blk .row{gap:4px}
|
||||
.ctl .k{font-size:8.5px;min-height:36px}
|
||||
.bank .k{font-size:8px;min-height:32px!important}
|
||||
.pads{flex:1 1 auto;min-height:0;gap:5px;grid-auto-rows:minmax(34px,1fr)}
|
||||
#strip .chips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;margin:0 -8px;padding:0 8px}
|
||||
#strip .chips::-webkit-scrollbar{display:none}
|
||||
#strip .lbl{display:none}
|
||||
#strip .chips+.chips{margin-top:6px}
|
||||
#info{margin:0;flex:0 0 auto;padding:10px 12px;min-height:116px}
|
||||
.clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.ititle{font-size:1.2rem;margin:2px 0 1px}
|
||||
.opts{margin-top:6px}
|
||||
body[data-tab="explore"] #pane{display:none}
|
||||
body[data-tab="find"] #unitwrap,body[data-tab="rules"] #unitwrap{display:none}
|
||||
#pane{margin:0}
|
||||
.bnav{display:flex;position:fixed;left:0;right:0;bottom:0;height:calc(var(--nav) + var(--sab));padding-bottom:var(--sab);background:var(--sheet);border-top:1px solid var(--line);z-index:10}
|
||||
.bnav button{flex:1;background:none;border:0;font-family:var(--fc);font-weight:600;font-size:1rem;color:var(--muted);position:relative;cursor:pointer}
|
||||
.bnav button[aria-selected="true"]{color:var(--ink)}
|
||||
.bnav button[aria-selected="true"]::before{content:"";position:absolute;top:0;left:30%;right:30%;height:3px;background:var(--lit);border-radius:0 0 3px 3px}
|
||||
.item{padding:12px 2px}
|
||||
}
|
||||
@media (max-width:820px) and (max-height:730px){
|
||||
.knobs .k{width:30px}.fx .k{min-height:24px}.ctl .k{min-height:31px}.bank .k{min-height:28px!important}.lcd .l3{display:none}
|
||||
}
|
||||
@media (max-width:820px) and (max-height:680px){ .knobs{display:none!important}.pads{grid-auto-rows:minmax(29px,1fr)}.opt{min-height:36px;padding:5px 8px}#info{min-height:0;padding:8px 10px}.ititle{font-size:1.1rem}.unit{gap:6px} }
|
||||
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body data-tab="explore">
|
||||
<div class="app">
|
||||
<header class="head"><h1>SP-404MK2 layers</h1><p>Hold a modifier and the panel relabels itself. From reference manual v5.50.</p></header>
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab" role="tab" data-tab="explore" aria-selected="true">Explore</button>
|
||||
<button class="tab" role="tab" data-tab="drill" aria-selected="false">Drill</button>
|
||||
<button class="tab" role="tab" data-tab="find" aria-selected="false">Find</button>
|
||||
<button class="tab" role="tab" data-tab="rules" aria-selected="false">The rules</button>
|
||||
</div>
|
||||
<div id="strip"></div>
|
||||
<div id="unitwrap"><div class="unit" id="unit"></div></div>
|
||||
<div id="info" aria-live="polite"></div>
|
||||
<div id="pane"></div>
|
||||
</div>
|
||||
<nav class="bnav" role="tablist">
|
||||
<button data-tab="explore" aria-selected="true">Explore</button>
|
||||
<button data-tab="drill" aria-selected="false">Drill</button>
|
||||
<button data-tab="find" aria-selected="false">Find</button>
|
||||
<button data-tab="rules" aria-selected="false">Rules</button>
|
||||
</nav>
|
||||
<div class="scrim" id="scrim"></div>
|
||||
<div class="sheet" id="sheet" role="dialog" aria-modal="true" aria-labelledby="stitle">
|
||||
<div class="shead"><b id="stitle"></b><button class="sclose" id="sclose" aria-label="Close">×</button></div>
|
||||
<div class="sbody" id="sbody"></div>
|
||||
</div>
|
||||
<script>
|
||||
/* ============ panel ============ */
|
||||
const L={vol:"VOLUME",c1:"CTRL 1",c2:"CTRL 2",c3:"CTRL 3",fx_fd:"FILTER +DRIVE",fx_res:"RESONATOR",fx_dly:"DELAY",fx_iso:"ISOLATOR",fx_djl:"DJFX LOOPER",mfx:"MFX",
|
||||
pat_sel:"PATTERN SELECT",pat_edit:"PATTERN EDIT",rec_set:"RECORD SETTING",del:"DEL",rec:"REC",resample:"RESAMPLE",exit:"EXIT",copy:"COPY",remain:"REMAIN",
|
||||
start_end:"START/END",pitch:"PITCH/SPEED",mark:"MARK",value:"VALUE",bpm_sync:"BPM SYNC",gate:"GATE",loop:"LOOP",reverse:"REVERSE",roll:"ROLL",
|
||||
bank_a:"A/F",bank_b:"B/G",bank_c:"C/H",bank_d:"D/I",bank_e:"E/J",shift:"SHIFT",busfx:"BUS FX",hold:"HOLD",ext:"EXT SOURCE",subpad:"SUB PAD"};
|
||||
for(let i=1;i<=16;i++)L["p"+i]="PAD "+i;
|
||||
const PL={pat_sel:"PTN SEL",pat_edit:"PTN EDIT",rec_set:"REC SET",resample:"RESAMPLE",reverse:"REVERSE",remain:"REMAIN",start_end:"START /END",pitch:"PITCH /SPEED"};
|
||||
const PLAB=id=>PL[id]||L[id];
|
||||
const GROUPS={pads:[...Array(16)].map((_,i)=>"p"+(i+1)),ctrls:["c1","c2","c3"],effects:["fx_fd","fx_res","fx_dly","fx_iso","fx_djl","mfx"],banks:["bank_a","bank_b","bank_c","bank_d","bank_e"]};
|
||||
const GNAME={pads:"PAD 1–16",ctrls:"CTRL 1–3",effects:"FX button",banks:"BANK A–E"};
|
||||
const NOLATCH=new Set(["vol","c1","c2","c3",...GROUPS.pads]);
|
||||
const isPad=id=>/^p\d/.test(id);
|
||||
function K(id,cls=""){return `<button class="k ${cls}" data-id="${id}" aria-label="${L[id]}"><span class="t">${isPad(id)?`<span class="n">${id.slice(1)}</span>`:PLAB(id)}</span></button>`}
|
||||
const $=id=>document.getElementById(id);
|
||||
$("unit").innerHTML=`
|
||||
<div class="row knobs">${K("vol","round")}${K("c1","round")}${K("c2","round")}${K("c3","round")}</div>
|
||||
<div class="fx"><div class="col">${K("fx_fd")}${K("fx_res")}${K("fx_dly")}</div>
|
||||
<div class="lcd" id="lcd"><div class="l1"></div><div class="l2"></div><div class="l3"></div></div>
|
||||
<div class="col">${K("fx_iso")}${K("fx_djl")}${K("mfx")}</div></div>
|
||||
<div class="ctl">
|
||||
<div class="blk"><div class="row">${K("pat_sel")}${K("pat_edit")}${K("rec_set")}</div><div class="row">${K("del")}${K("rec")}${K("resample")}</div><div class="row">${K("exit")}${K("copy")}${K("remain")}</div></div>
|
||||
<div class="blk"><div class="row">${K("start_end")}${K("pitch")}${K("mark")}${K("value","round valk")}</div>
|
||||
<div class="row">${K("bpm_sync")}${K("gate")}${K("loop")}${K("reverse")}${K("roll")}</div>
|
||||
<div class="row bank">${K("bank_a")}${K("bank_b")}${K("bank_c")}${K("bank_d")}${K("bank_e")}${K("shift")}</div></div>
|
||||
</div>
|
||||
<div class="pads">
|
||||
${[1,2,3,4].map(i=>K("p"+i)).join("")}${K("busfx","side")}
|
||||
${[5,6,7,8].map(i=>K("p"+i)).join("")}${K("hold","side")}
|
||||
${[9,10,11,12].map(i=>K("p"+i)).join("")}${K("ext","side")}
|
||||
${[13,14,15,16].map(i=>K("p"+i)).join("")}${K("subpad","side")}
|
||||
</div>`;
|
||||
const KEY={};document.querySelectorAll(".k").forEach(b=>KEY[b.dataset.id]=b);
|
||||
|
||||
/* ============ data: [mods, target, ctx, short, name, desc, page, how] ============ */
|
||||
const RAW=[
|
||||
// SHIFT + pads
|
||||
[["shift"],"p1","S","FIX VEL","Fixed velocity","Every hit plays at 127. Stored per sample; same combo turns it off.",24],
|
||||
[["shift"],"p2","S","16 VEL","16 velocity","Pads become 16 velocity steps of the selected sample. EXIT to leave.",24],
|
||||
[["shift"],"p3","S","CUE","Cue mode","Send a sample to headphones only. CTRL 3 balances cue against the main mix. EXIT to leave.",101],
|
||||
[["shift"],"p4","S","CHROMA","Chromatic mode","Play the selected sample across the pads in pitch. EXIT to leave.",25],
|
||||
[["shift"],"p5","S P","SWAP","Exchange pads","Swap two samples (or two patterns in pattern mode). Bank buttons reach other banks. Confirm with VALUE or COPY.",60],
|
||||
[["shift"],"p6","S","INIT","Init sample params","Resets start/end, tempo, pitch and the rest for the selected pad.",59],
|
||||
[["shift"],"p7","S","LINK","Pad link groups","Groups A–J of up to 4 pads that fire together from any member.",26],
|
||||
[["shift"],"p8","S","MUTE GRP","Mute groups","Choke groups A–J: only the last-hit sample in a group sounds.",28],
|
||||
[["shift"],"p9","S P L","METRO","Metronome on/off","Also works while sampling and recording patterns.",38],
|
||||
[["shift"],"p10","S P","COUNT-IN","Count-in","Cycles 1 bar, 2 bars, WAIT (starts on input level), off.",38],
|
||||
[["shift"],"p11","S","TEMPO","Tempo select / tap tempo","TEMPO SEL screen: CTRL 1 picks project or bank tempo, VALUE sets it (SHIFT+VALUE for decimals). SUB PAD blinks: tap it for tap tempo.",91],
|
||||
[["shift"],"p12","S","GAIN","Input gain","Jumps to UTILITY > SYSTEM > GAIN.",160],
|
||||
[["shift"],"p13","S","UTILITY","Utility menu","System settings, rename, factory reset.",111],
|
||||
[["shift"],"p14","S","IMP/EXP","Import / export","SD import and export, backup and restore, format SD card.",123],
|
||||
[["shift"],"p15","S","PAD SET","Pad settings","Pad LED mode, bus colors and other pad behaviour.",112],
|
||||
[["shift"],"p16","S","EFX SET","Effect settings","BUS 3/4 effects, side chain, DIRECT FX button assignment, input FX, routing.",113],
|
||||
// SHIFT + buttons
|
||||
[["shift"],"busfx","S P","MUTE BUS","Mute bus","Cuts the feed into BUS 1/2 so only reverb or delay tails ring out. Turning it off also turns the bus effect off.",34],
|
||||
[["shift"],"hold","S","PAUSE","Pause all","Pauses every playing sample; again to resume.",28],
|
||||
[["shift"],"ext","S","IN SET","Input settings","Opens the input setting screen.",160],
|
||||
[["shift"],"subpad","S","PROJECT","Project select","Pads load projects 1–16. Inside this screen, SUB PAD loads projects straight from SD (/EXPORT/PROJECT/PROJECT_01…16).",104],
|
||||
[["shift"],"mark","S","SAVE FX","Save BUS 1/2 effects","Hold MARK for 3 seconds. Saves the main parameters of the BUS 1/2 effects so they survive power-off.",33,"Hold SHIFT, hold MARK 3 s"],
|
||||
[["shift"],"pitch","S E","ENVELOPE","Envelope","Attack, hold, release per sample.",53],
|
||||
[["shift"],"start_end","S E","MARKERS","Marker / chop screen","Place markers with CTRL 1 + MARK, or tap pads 2–16 in time while pad 1 plays. VALUE opens AUTO MARK, ASSIGN TO PAD (chop), DELETE ALL MARKERS.",50],
|
||||
[["shift"],"pat_sel","P L","UNDO","Undo last take","While recording a pattern: undo what you just recorded. In the looper: undo, and again to redo.",66],
|
||||
[["shift"],"roll","S","ROLL RATE","Roll interval","Cycles 1/4 down to 1/64, then the triplet values.",23],
|
||||
[["shift"],"loop","S","PINGPONG","Ping-pong loop","Loop alternates forward and backward (LOOP blinks).",22],
|
||||
[["shift"],"gate","S","GATE ALL","Gate all in bank","Toggles GATE for every sample in the bank.",21],
|
||||
[["shift"],"bpm_sync","S","SYNC ALL","BPM sync all in bank","Toggles BPM SYNC for every sample in the bank.",20],
|
||||
[["shift"],"rec","P","TR-REC","Jump into TR-REC","While a pattern plays: step-record on top of it.",64],
|
||||
[["shift"],"banks","S","BANK VOL","Bank volume","Level for a whole bank, to even out banks.",19],
|
||||
[["shift"],"remain","S","LIVE","Live mode","Hold REMAIN 3 seconds. Disables DEL, REC, RESAMPLE, COPY and MARK so you cannot wreck a set. Same again to exit.",29,"Hold SHIFT, hold REMAIN 3 s"],
|
||||
[["shift"],"copy","S","PROTECT","Bank protect","Locks samples and patterns of the selected bank. Same again to unlock.",62],
|
||||
[["shift"],"value","S","MARK FN","MARK = skip-back or looper","Pressing the VALUE knob flips what MARK does.",44,"Hold SHIFT, press VALUE"],
|
||||
[["shift"],"resample","S","MERGE","Sample merge","Pick up to 4 pads. CTRL 1: SUM (mix down) or MUL (multiply, ring-mod style). CTRL 3 per-pad level. Pick destination, press VALUE.",27],
|
||||
[["shift"],"rec_set","S","SYNTH","Sound generator","Built-in oscillator (sine to noise) playable on pads or via MIDI notes; sample the result.",46],
|
||||
[["shift"],"exit","S P D L","STOP","Stop everything","Stops all samples and patterns. Same as tapping EXIT four times fast.",28],
|
||||
[["shift"],"c1","S E","FINE","Fine CTRL 1","Finer steps for REC BPM, SPEED and similar values.",56,"Hold SHIFT, turn CTRL 1"],
|
||||
[["shift"],"value","E","NUM/VINYL","Numeric entry / vinyl mode","START/END screen: press VALUE to type point positions on pads 1–10. PITCH/SPEED screen: turn VALUE to toggle VINYL mode (speed and pitch linked like a turntable).",48,"Hold SHIFT, press or turn VALUE"],
|
||||
[["shift"],"c2","E","FINE PIT","Fine pitch","PITCH/SPEED screen: cents. START/END and marker screens: SHIFT + any CTRL zooms.",57,"Hold SHIFT, turn CTRL 2"],
|
||||
[["shift"],"c3","E","PAN","Sample pan","PITCH/SPEED screen.",57,"Hold SHIFT, turn CTRL 3"],
|
||||
[["shift"],"del","P","REVERT","Revert chain edits","Pattern chain editor: discard edits (the * mark).",75],
|
||||
[["shift"],"mark","P","ERASE FX","FX motion erase on/off","During real-time pattern recording: press once where erasing should start, again where it should stop.",78],
|
||||
// SHIFT + REVERSE
|
||||
[["shift","reverse"],"pads","S P","MUTE","Pad mute","Mute or unmute pads while they play (muted pads light red). Not on pads holding patterns.",26],
|
||||
[["shift","reverse"],"remain","S","LATCH","Pad mute latch","Keeps SHIFT and REVERSE held so you can tap mutes freely. EXIT cancels.",26],
|
||||
[["shift","reverse"],"remain","P","MUTE REC","Pad mute motion rec","During real-time pattern recording: records your pad mutes into the pattern. DEL flips to erase mode.",79],
|
||||
// plain presses that hide things
|
||||
[[],"exit","S P D L","STOP ×4","Panic stop","Four quick taps stop everything and also clear pad mutes.",28,"Tap EXIT four times fast"],
|
||||
[[],"remain","S","INFO","Pad info","Hold to see the selected pad's parameters; while it plays, the remaining time.",59],
|
||||
[[],"mark","S","SKIPBACK","Skip-back sampling","When MARK blinks, the unit has been capturing the last 25 s (40 s with SBS Long). MARK, then REC, then pick a pad. Lost on EXIT or power-off.",41],
|
||||
[["bank_d"],"bank_e","S","DJ MODE","Enter DJ mode","Press D/I and E/J together.",93,"Press D/I and E/J together"],
|
||||
[[],"value","E","MENU","Hidden edit menu","START/END screen: TRUNCATE, NORMALIZE, EMPHASIS. Marker screen: AUTO MARK, ASSIGN TO PAD, DELETE ALL MARKERS.",49,"Press VALUE on an edit screen"],
|
||||
[[],"resample","E","0-CROSS","Snap to zero-cross","On START/END and marker screens a lit RESAMPLE snaps points to zero crossings.",49],
|
||||
[[],"mark","E","SET S/E","Set start/end by ear","START/END screen, while playing: MARK sets start, then end. Marker screen: MARK drops a marker at the cursor.",48],
|
||||
[[],"roll","E","PREV/GROOVE","Preview end / groove","START/END screen: hold to hear the seconds before the end point. PITCH/SPEED screen: ROLL opens the Groove template screen (humanize, rate, swing).",48],
|
||||
[[],"remain","E","LOCK PTS","Point lock","START/END screen: stops the start point from pushing the loop and end points.",48],
|
||||
[[],"del","E","RESET","Reset points","START/END screen: resets start/end. Marker screen: deletes the selected marker.",48],
|
||||
[["remain"],"ctrls","E","WHOLE BANK","Edit the whole bank","PITCH/SPEED or ENVELOPE screen: knob moves while holding REMAIN change every sample in the bank.",58,"Hold REMAIN, turn CTRL"],
|
||||
[["copy"],"ctrls","E","MUTE GRP","Edit the mute group","Same as above, but only samples in the same mute group.",58,"Hold COPY, turn CTRL"],
|
||||
[["mark"],"pads","E","SILENT","Select silently","ENVELOPE screen: pick a sample without playing it.",53],
|
||||
// REMAIN layer
|
||||
[["remain"],"pads","S","BUS","Pad → bus","Holding REMAIN shows bus colors (orange BUS 1, green BUS 2, white DRY). Tap a pad to toggle it between the bus picked by BUS FX and DRY.",34],
|
||||
[["remain"],"banks","S","BANK BUS","Bank → bus","Route an entire bank at once.",34],
|
||||
[["remain"],"ext","S","IN BUS","Input → bus","Route INPUT / LINE IN: lit BUS 1, blinking BUS 2, dark DRY.",34],
|
||||
[["remain"],"effects","S","LOCK","Lock FX screen","Effect edit screen stays up. VALUE flips main/sub parameters. EXIT or REMAIN to leave.",33],
|
||||
[["remain"],"busfx","S","SWAP 1⇄2","Swap BUS 1 and 2","Swaps the effects and keeps their values.",162],
|
||||
[["remain"],"value","S","COLOR","Pad color","With Pad LED Mode set to SAMPLE (SHIFT + pad 15): hold REMAIN, turn VALUE to Color, press, pick.",59,"Hold REMAIN, turn VALUE"],
|
||||
// VALUE layer
|
||||
[["value"],"pads","S P","SILENT","Select without playing","Pick a sample, or a pattern in pattern mode, with no sound.",162],
|
||||
[["value"],"busfx","S","BYP 3/4","Bypass BUS 3 and 4","Temporarily bypass the master-bus effects.",162],
|
||||
[["value"],"subpad","S","TIGHT","Tighten timing","Reduces flams when triggering several samples at once.",162],
|
||||
[["value"],"effects","S","GRAB","Effect grab","Effect buttons become momentary instead of toggles.",32],
|
||||
[["value"],"gate","S","1-SHOT","One-shot mode","Sample plays once to its end regardless of pad release.",162],
|
||||
[["value"],"ctrls","S","SUB PRM","Effect sub-parameters","Knobs edit the current effect's hidden second page.",32,"Hold VALUE, turn CTRL"],
|
||||
// MFX layer
|
||||
[["mfx"],"pads","S","FX 1–16","Pick MFX by pad","Pads select effects 1–16 directly.",32],
|
||||
[["mfx"],"fx_djl","S","#17+","Pads = MFX 17–32","Shifts the pad map to the second page of effects.",32],
|
||||
[["mfx"],"fx_iso","S","#33+","Pads = MFX 33+","Third page of effects.",32],
|
||||
[["mfx"],"value","S","BROWSE","Browse MFX","Turn VALUE or CTRL 3 to scroll the effect list.",32,"Hold MFX, turn VALUE"],
|
||||
// DEL / COPY
|
||||
[["del"],"exit","S P","DEL BANK","Delete bank","Choose a bank, confirm with VALUE or DEL.",62],
|
||||
[["copy"],"exit","S P","COPY BANK","Copy bank","Source bank, turn VALUE, destination bank, press VALUE. CTRL 3 targets another project.",61],
|
||||
[["del"],"mark","P","CLR FX","Clear FX motion","Pattern edit screen: wipes all recorded effect moves.",78],
|
||||
[["del"],"reverse","P","CLR MUTE","Clear mute motion","Pattern edit screen: wipes all recorded pad mutes.",80],
|
||||
[["del"],"value","P","DEL PTNS","Delete patterns in loop","Pattern edit: hold DEL (loop range lights blue), tap pads to pick, press VALUE.",81,"Hold DEL, tap pads, press VALUE"],
|
||||
[["del"],"bank_a","T","CLR BAR","Clear bar, this pad","TR-REC: erase one measure of the selected pad.",69],
|
||||
[["del"],"bank_b","T","CLR BAR*","Clear bar, all pads","TR-REC: erase one measure for every pad.",69],
|
||||
// ROLL / HOLD / SUB PAD
|
||||
[["roll"],"pads","S","ROLL","Roll a pad","Up to 4 at once. Also: hold a pad, then press ROLL.",23],
|
||||
[["roll"],"value","S","FASTER","Speed up a roll live","Turn right to shorten the interval while rolling; turning left returns to the set value.",24,"Hold ROLL, turn VALUE"],
|
||||
[["roll"],"pads","P","ROLL","Record a roll","During real-time recording the roll is written into the pattern.",80],
|
||||
[["roll"],"ctrls","T","KNOB REC","Step-record knobs","TR-REC in TRIG mode: records CTRL moves per step.",70,"Hold ROLL, turn CTRL"],
|
||||
[["hold"],"pads","S","SUSTAIN","Sustain gated pads","Hold pads, then press HOLD: gated samples keep playing. HOLD with a looping pad retriggers it from the start.",21,"Hold pad(s), press HOLD"],
|
||||
[["hold"],"pads","P","CHAIN","Pattern chain","Pick chain 1–16. Then pads add patterns, VALUE moves the cursor, DEL removes, REMAIN sets repeat, SUB PAD plays it.",74],
|
||||
[["subpad"],"pads","P","NOW","Switch pattern instantly","No waiting for the current pattern to finish.",64],
|
||||
[[],"subpad","P","CHAIN ▶","Play pattern chain","Inside the pattern chain screen: start and stop the chain.",75],
|
||||
// pattern plain
|
||||
[[],"mark","P","FX REC","FX motion record","During real-time pattern recording: MARK starts recording effect moves and knob turns.",77],
|
||||
[[],"start_end","P","FX PLAY","FX motion playback","Blinking: recorded effect moves play back. Unlit: ignored.",78],
|
||||
[[],"resample","P","RESAMPLE","Resample a pattern","In pattern mode: RECORD SETTING, ROUTING Mix, pick an empty pad, pick the pattern. Keeps effects, unlike bounce.",72],
|
||||
[[],"rec","P","REHEARSE","Rehearsal toggle","While recording a pattern, REC flips between writing and rehearsal (keeps looping, writes nothing).",66],
|
||||
[[],"del","P","ERASE","Erase while recording","During pattern recording: DEL enters erase mode; hold pads to wipe their notes as the loop passes.",66],
|
||||
[[],"remain","P","MODE","REMAIN in pattern screens","RECORD SETTING: Real-Time (lit) or TR-REC (blinking). Chain screen: repeat on/off. Copy screen: pick only some pads to extract.",65],
|
||||
[[],"pat_edit","P","EDIT","Pattern edit","BPM, loop start, length. VALUE: DUPLICATE, CROP. RECORD SETTING: quantize.",80],
|
||||
[[],"rec_set","P","QUANTIZE","Quantize","On the pattern edit screen: quantize recorded notes. Cannot be undone.",83],
|
||||
[[],"copy","P","COPY/BOUNCE","Copy, extract, bounce","COPY, source, destination. REMAIN in between extracts only chosen pads. To bounce to audio: pick with COPY, press PATTERN SELECT, choose a pad. COPY then HOLD + pad bounces a chain.",85],
|
||||
// TR-REC
|
||||
[["subpad"],"pads","T","PICK","Choose pad to program","Selects which sample's steps you're editing. Press VALUE too to choose silently.",68],
|
||||
[["pat_edit"],"pads","T","MICRO","Microscope","Edit a recorded note: CTRL 1 note, CTRL 2 pitch, CTRL 3 velocity, VALUE nudges timing, DEL deletes.",71],
|
||||
[[],"remain","T","TRIG/TIE","Step mode","TRIG: each step hits. HOLD STEP: steps tie together (forces GATE on).",69],
|
||||
[[],"hold","T","SOLO","Solo selected pad","Hear only the pad you are programming.",69],
|
||||
[[],"pitch","T","PITCH MD","Step pitch mode","CHROMATIC: pitch per step (VALUE picks scale). PAD: one fixed pitch.",68],
|
||||
[[],"ctrls","T","SUB/PIT/VEL","Step knobs","CTRL 1 substep (or tie length), CTRL 2 pitch, CTRL 3 velocity. With SHIFT: CTRL 2 shuffle, CTRL 3 start offset.",68],
|
||||
[[],"value","T","BAR","Select bar","Turn to page through measures.",69],
|
||||
[[],"rec","T","TAP","Tap record","Press REC in time to drop the selected pad in. RECORD SETTING enables quantize and shuffle for it.",71],
|
||||
// Looper
|
||||
[[],"mark","L","EXIT","Leave looper","MARK toggles looper mode (when MARK function is set to Looper).",45],
|
||||
[[],"rec","L","REC","Loop rec / stop","Starts and stops recording.",45],
|
||||
[[],"resample","L","OVERDUB","Overdub","Light it before recording to switch to overdub after the first pass.",44],
|
||||
[[],"del","L","CLEAR","Clear loop","Only when stopped.",45],
|
||||
[[],"copy","L","TO PAD","Save loop to pad","Pick a pad, press VALUE.",45],
|
||||
[[],"ctrls","L","LEN/TRIG/BPM","Looper knobs","CTRL 1 length (free, 1, 2, 4 bars), CTRL 2 auto trigger, CTRL 3 BPM or play rate.",44],
|
||||
[["shift"],"c1","L","BUS","Looper bus","DRY, BUS 1, BUS 2 or LOOPER. Use LOOPER when overdubbing.",44,"Hold SHIFT, turn CTRL 1"],
|
||||
[["shift"],"c2","L","ROUTING","Looper source","Mix (everything) or ExtIn (input only).",44,"Hold SHIFT, turn CTRL 2"],
|
||||
[["shift"],"value","L","LVL/FB","Rec level / feedback","Press VALUE to choose which one the knob edits.",44,"Hold SHIFT, turn VALUE"],
|
||||
[["pitch"],"pads","L","TEMPO←","Take a pad's tempo","Looper tempo becomes that pad's BPM.",45],
|
||||
// Chromatic / synth
|
||||
[[],"remain","X","PLAY MD","Legato / mono / poly","Chromatic mode: cycles legato (slow blink), mono (lit), poly (fast blink).",25],
|
||||
[[],"value","X","SCALE","Scale and range","Chromatic mode: press to cycle major, minor, dorian, phrygian, major and minor pentatonic. Turn to move the key range.",26],
|
||||
[["roll"],"value","X","ROOT","Scale root","Chromatic mode: hold ROLL and turn VALUE.",26,"Hold ROLL, turn VALUE"],
|
||||
[[],"ctrls","X","OSC","Synth knobs","Sound generator: CTRL 1 waveform, CTRL 2 frequency, CTRL 3 level. With SHIFT: pad length, duty cycle, balance.",46],
|
||||
// DJ
|
||||
[[],"p13","D","CH1 ▶‖","CH1 play / pause","",94],[[],"p15","D","CH2 ▶‖","CH2 play / pause","",94],
|
||||
[[],"p9","D","CH1 ⏮","CH1 back to start","",94],[[],"p11","D","CH2 ⏮","CH2 back to start","",94],
|
||||
[[],"p14","D","CH1 CUE","CH1 to headphones","",94],[[],"p16","D","CH2 CUE","CH2 to headphones","",94],
|
||||
[[],"p10","D","SYNC 1→2","CH1 follows CH2 tempo","",94],[[],"p12","D","SYNC 2→1","CH2 follows CH1 tempo","",94],
|
||||
[[],"p2","D","CH1 BPM+","CH1 tempo up","",94],[[],"p6","D","CH1 BPM−","CH1 tempo down","",94],
|
||||
[[],"p4","D","CH2 BPM+","CH2 tempo up","",94],[[],"p8","D","CH2 BPM−","CH2 tempo down","",94],
|
||||
[[],"p1","D","CH1 BEND+","CH1 nudge faster","Pitch and speed rise while held, like pushing a platter.",94],[[],"p5","D","CH1 BEND−","CH1 nudge slower","",94],
|
||||
[[],"p3","D","CH2 BEND+","CH2 nudge faster","",94],[[],"p7","D","CH2 BEND−","CH2 nudge slower","",94],
|
||||
[["del"],["p2","p6"],"D","CH1 RST","CH1 tempo reset","",94],[["del"],["p4","p8"],"D","CH2 RST","CH2 tempo reset","",94],
|
||||
[["remain"],"p14","D","CH1 BUS","CH1 bus cycle","BUS 1, BUS 2, DRY.",94],[["remain"],"p16","D","CH2 BUS","CH2 bus cycle","BUS 1, BUS 2, DRY.",94],
|
||||
[["roll"],"p13","D","CH1 ROLL","CH1 roll","Fails if the roll size is longer than the sample.",164],[["roll"],"p15","D","CH2 ROLL","CH2 roll","",164],
|
||||
[["roll"],"p1","D","¼ BAR","Roll: quarter bar","Changes the interval while rolling.",164],[["roll"],"p2","D","½ BAR","Roll: half bar","",164],
|
||||
[["roll"],"p3","D","1 BAR","Roll: 1 bar","",164],[["roll"],"p4","D","2 BARS","Roll: 2 bars","",164],
|
||||
[["shift"],"roll","D","ROLL SZ","Roll size","1/4, 1/2, 1 or 2 bars. Set before rolling.",164],
|
||||
[["resample"],"value","D","CURVE","Fader curve","FAST CUT, LINEAR, SQUARE, CUBIC.",95,"Hold RESAMPLE, press VALUE"],
|
||||
[[],"hold","D","PAD MODE","DJ / pad mode","Switch between DJ controls and normal pad playback.",96],
|
||||
[[],"start_end","D","X-FADE","CTRL 3: cue mix / crossfade","Swaps what CTRL 3 does.",94],
|
||||
[[],"pitch","D","BPM .00","BPM decimals","Cycles BPM display precision.",163],
|
||||
[[],"mark","D","VIEW","EFX / mixer view","Toggles the screen.",163],
|
||||
[[],"bpm_sync","D","REV CH","Reverse channel","Chooses whether REVERSE acts on CH1 or CH2.",163],
|
||||
[["shift"],"pads","D","CUE PT","Play from marker","Jump to a marker. Pads 1/2 are CH1/CH2 starts, others map to markers 1–7 (manual p. 96).",96],
|
||||
[["shift"],"remain","D","SHIFT LCK","Shift latch","Keeps SHIFT held for marker juggling. EXIT cancels.",163],
|
||||
[["shift"],"mark","D","+MARKER","Add marker","Drop markers while playing.",97],
|
||||
[["shift"],"start_end","D","EDIT MRK","Edit markers","Move markers while playing.",97],
|
||||
[["shift","del"],"pads","D","DEL MRK","Delete a marker","Pad map as for playing from markers. Later markers shift down.",98],
|
||||
[["shift","reverse"],["p13","p15"],"D","MUTE","Channel mute","Pad 13 mutes CH1, pad 15 mutes CH2.",163],
|
||||
];
|
||||
const CTX={S:"Sample mode",E:"Edit screens",P:"Pattern",T:"TR-REC",L:"Looper",D:"DJ mode",X:"Chromatic / synth"};
|
||||
const CTXHINT={S:"Top screen, pads orange.",E:"START/END, marker, PITCH/SPEED and envelope screens.",P:"PATTERN SELECT lit, pads purple. Includes real-time recording and the chain editor.",T:"Step recording (REC in pattern mode, REMAIN until it blinks).",L:"MARK function set to Looper, then MARK.",D:"Press D/I and E/J together.",X:"SHIFT + pad 4, or SHIFT + RECORD SETTING."};
|
||||
const MODORDER=["shift","reverse","remain","value","del","copy","mfx","roll","hold","subpad","pat_edit","mark","pitch","resample","bank_d"];
|
||||
const ENTRIES=RAW.map((r,i)=>{const [mods,target,ctx,short,name,desc,page,how]=r;
|
||||
const mk=[...mods].sort((a,b)=>MODORDER.indexOf(a)-MODORDER.indexOf(b));
|
||||
const tset=Array.isArray(target)?target:(GROUPS[target]||[target]);
|
||||
return {i,mods:mk,key:mk.join("+"),target,tset,ctx:ctx.split(" "),short,name,desc,page,how,id:mk.join("+")+">"+[].concat(target).join("/")+"@"+ctx+"#"+short};
|
||||
});
|
||||
const tName=t=>Array.isArray(t)?t.map(x=>L[x]).join(" / "):(GNAME[t]||L[t]);
|
||||
const mName=m=>L[m];
|
||||
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
|
||||
const caps=arr=>arr.map(p=>`<span class="cap">${esc(p)}</span>`).join('<span class="plus">+</span>');
|
||||
function combo(e,withHow=true){return caps([...e.mods.map(mName),tName(e.target)])+(withHow&&e.how?`<div class="muted" style="font-size:.8rem;margin-top:2px">${esc(e.how)}</div>`:"")}
|
||||
const mobile=()=>matchMedia("(max-width:820px)").matches;
|
||||
const buzz=(ms=8)=>{try{navigator.vibrate&&navigator.vibrate(ms)}catch(e){}};
|
||||
|
||||
/* ============ storage ============ */
|
||||
const SK="sp404mk2-layers-v1";
|
||||
function load(){try{return JSON.parse(localStorage.getItem(SK))||{}}catch(e){return {}}}
|
||||
function save(){try{localStorage.setItem(SK,JSON.stringify(store))}catch(e){}}
|
||||
let store=load();store.boxes=store.boxes||{};
|
||||
|
||||
const st={tab:"explore",ctx:store.ctx||"S",held:[],sel:null,
|
||||
deck:store.deck||"shiftpads",dir:store.dir||"play",
|
||||
input:store.input||(matchMedia("(pointer:coarse)").matches?"hold":"tap"),
|
||||
card:null,chord:[],answered:false,last:null,opts:null};
|
||||
|
||||
const layerKey=a=>[...a].sort((x,y)=>MODORDER.indexOf(x)-MODORDER.indexOf(y)).join("+");
|
||||
const layerEntries=()=>{const k=layerKey(st.held);return ENTRIES.filter(e=>e.ctx.includes(st.ctx)&&e.key===k)};
|
||||
function lcd(a,b,c){const d=$("lcd");d.children[0].textContent=a;d.children[1].textContent=b;d.children[2].textContent=c}
|
||||
function clearPanel(){for(const id in KEY){const b=KEY[id];b.classList.remove("target","sel","held","chord","good","miss");b.querySelector(".t").innerHTML=isPad(id)?`<span class="n">${id.slice(1)}</span>`:PLAB(id);const c=b.querySelector(".cnt");if(c)c.remove()}}
|
||||
|
||||
/* ============ sheet ============ */
|
||||
let sheetOnClose=null;
|
||||
function openSheet(title,html,after){$("stitle").textContent=title;$("sbody").innerHTML=html;$("sheet").classList.add("open");$("scrim").classList.add("open");$("sbody").scrollTop=0;
|
||||
try{history.pushState({sheet:1},"")}catch(e){} if(after)after($("sbody"));}
|
||||
function closeSheet(fromPop){if(!$("sheet").classList.contains("open"))return;$("sheet").classList.remove("open");$("scrim").classList.remove("open");
|
||||
if(!fromPop){try{if(history.state&&history.state.sheet)history.back()}catch(e){}} if(sheetOnClose){const f=sheetOnClose;sheetOnClose=null;f()}}
|
||||
$("scrim").onclick=()=>closeSheet();$("sclose").onclick=()=>closeSheet();
|
||||
addEventListener("popstate",()=>closeSheet(true));
|
||||
|
||||
/* ============ explore ============ */
|
||||
function listHTML(es){return `<div class="list">${es.map(e=>`<button class="item ${st.sel===e?"on":""}" data-e="${e.i}"><span class="nm">${esc(e.name)}</span><span class="combo">${combo(e,false)}</span><span class="pg">p.${e.page}</span></button>`).join("")}</div>`}
|
||||
function renderExplore(){
|
||||
clearPanel();
|
||||
st.held.forEach(h=>KEY[h].classList.add("held"));
|
||||
const es=layerEntries(),byT={};
|
||||
es.forEach(e=>e.tset.forEach(t=>(byT[t]=byT[t]||[]).push(e)));
|
||||
for(const t in byT){const b=KEY[t];b.classList.add("target");
|
||||
if(st.sel&&es.includes(st.sel)&&st.sel.tset.includes(t))b.classList.add("sel");
|
||||
b.querySelector(".t").textContent=byT[t][0].short;
|
||||
if(byT[t].length>1){const c=document.createElement("span");c.className="cnt";c.textContent=byT[t].length;b.appendChild(c)}}
|
||||
const lname=st.held.length?st.held.map(mName).join(" + "):"no modifier";
|
||||
if(st.sel)lcd(lname.toUpperCase(),st.sel.short,st.sel.name);
|
||||
else lcd(CTX[st.ctx].toUpperCase(),st.held.length?lname:"—",es.length?es.length+" functions here":"nothing on this layer");
|
||||
// strip
|
||||
const counts={};ENTRIES.filter(e=>e.ctx.includes(st.ctx)).forEach(e=>counts[e.key]=(counts[e.key]||0)+1);
|
||||
const keys=Object.keys(counts).sort((a,b)=>a===""?-1:b===""?1:(MODORDER.indexOf(a.split("+")[0])-MODORDER.indexOf(b.split("+")[0]))||a.length-b.length);
|
||||
const cur=layerKey(st.held);
|
||||
$("strip").innerHTML=`<div class="lbl">Where you are</div><div class="chips">${Object.keys(CTX).map(c=>`<button class="chip" data-ctx="${c}" aria-pressed="${c===st.ctx}">${CTX[c]}</button>`).join("")}</div>
|
||||
<div class="lbl">Layers in this mode</div><div class="chips">${keys.map(k=>`<button class="chip" data-layer="${k}" aria-pressed="${k===cur}">${k===""?"No modifier":k.split("+").map(mName).join(" + ")}<small>${counts[k]}</small></button>`).join("")}</div>`;
|
||||
keepChipsInView();
|
||||
// info
|
||||
const m=mobile();
|
||||
const listBtn=m?`<button class="btn ghost sm" data-openlist>List ${es.length}</button>`:"";
|
||||
const heldBtn=st.held.length?`<button class="btn ghost sm onlyd" data-release>Release ${esc(st.held.map(mName).join(" + "))}</button>`:"";
|
||||
if(st.sel){const e=st.sel;const long=m&&e.desc&&e.desc.length>80;
|
||||
$("info").innerHTML=`<div class="ihead"><div>${combo(e,!m)}</div><div class="ihr"><span class="muted" style="font-size:.78rem">p.${e.page}</span>${long?`<button class="btn ghost sm" data-more="${e.i}">More</button>`:""}${listBtn}</div></div>
|
||||
<div class="ititle">${esc(e.name)}</div>${e.desc?`<div class="idesc ${m?"clamp":""}">${esc(e.desc)}</div>`:""}
|
||||
${m?(e.how?`<div class="muted" style="font-size:.8rem">${esc(e.how)}</div>`:""):`<div class="muted" style="font-size:.8rem;margin-top:2px">${esc(e.ctx.map(c=>CTX[c]).join(", "))}</div><div class="irow">${heldBtn}</div>`}`;
|
||||
}else{
|
||||
$("info").innerHTML=`<div class="ihead"><div class="muted">${esc(CTXHINT[st.ctx])}</div><div class="ihr">${listBtn}</div></div><div style="margin-top:4px">${m?"Hold a button to peek at its layer, tap it to lock, tap again to release. Tap a lit control to read it.":"Click a button to hold it. Click a lit control or a row to read it."}</div>${m?"":`<div class="irow">${heldBtn}</div>`}`;
|
||||
}
|
||||
if(!m)$("pane").innerHTML=listHTML(es);
|
||||
}
|
||||
function keepChipsInView(){requestAnimationFrame(()=>document.querySelectorAll('#strip .chip[aria-pressed="true"]').forEach(c=>{const p=c.parentElement;if(p.scrollWidth>p.clientWidth){p.scrollLeft=c.offsetLeft-p.clientWidth/2+c.offsetWidth/2}}))}
|
||||
|
||||
/* ============ drill ============ */
|
||||
const DECKS={
|
||||
shiftpads:{n:"SHIFT + pads",f:e=>e.key==="shift"&&/^p\d+$/.test(e.target)&&e.ctx.includes("S")},
|
||||
shiftbtn:{n:"SHIFT + buttons",f:e=>e.key.startsWith("shift")&&!/^p\d+$/.test(e.target)&&e.ctx.includes("S")},
|
||||
mods:{n:"REMAIN, VALUE, DEL, COPY, MFX",f:e=>/^(remain|value|del|copy|mfx)/.test(e.key)&&e.ctx.some(c=>"SE".includes(c))},
|
||||
pattern:{n:"Pattern + TR-REC",f:e=>e.ctx.some(c=>c==="P"||c==="T")},
|
||||
dj:{n:"DJ mode",f:e=>e.ctx.includes("D")},
|
||||
edit:{n:"Edit, looper, chromatic",f:e=>e.ctx.some(c=>"ELX".includes(c))},
|
||||
all:{n:"Everything",f:()=>true},
|
||||
};
|
||||
function pressable(e){if(e.target==="ctrls")return false;if(e.how&&/turn|four|tap pads/i.test(e.how))return false;if(!e.mods.length&&e.tset.length>1)return false;return true}
|
||||
const pool=()=>ENTRIES.filter(DECKS[st.deck].f).filter(e=>st.dir==="name"||pressable(e));
|
||||
const boxOf=e=>store.boxes[e.id]||1;
|
||||
function pick(){const p=pool();if(!p.length)return null;
|
||||
const w=p.map(e=>e===st.last&&p.length>1?0:Math.pow(2,5-boxOf(e)));let r=Math.random()*w.reduce((a,b)=>a+b,0);
|
||||
for(let i=0;i<p.length;i++){r-=w[i];if(r<=0)return p[i]}return p[p.length-1]}
|
||||
function grade(ok){store.boxes[st.card.id]=ok?Math.min(5,boxOf(st.card)+1):1;save();st.answered=true;st.lastOk=ok;buzz(ok?[12,40,12]:40)}
|
||||
function nextCard(){st.card=pick();st.last=st.card;st.chord=[];st.answered=false;st.opts=null;renderDrill()}
|
||||
function isCorrect(chord){const e=st.card,ch=new Set(chord);if(!e.mods.every(m=>ch.has(m)))return false;
|
||||
const rest=[...ch].filter(x=>!e.mods.includes(x));return rest.length===1&&e.tset.includes(rest[0])}
|
||||
function dist(){const b=[0,0,0,0,0];pool().forEach(e=>b[boxOf(e)-1]++);return b}
|
||||
function renderDrill(){
|
||||
clearPanel();
|
||||
const p=pool();
|
||||
if(!st.card||!p.includes(st.card)){st.card=pick();st.last=st.card;st.chord=[];st.answered=false;st.opts=null}
|
||||
const b=dist(),mx=Math.max(1,...b);
|
||||
$("strip").innerHTML=`<button class="stripbtn" data-settings><span style="flex:1;min-width:0"><b>${esc(DECKS[st.deck].n)}</b><br><span class="muted" style="font-size:.82rem">${st.dir==="play"?"Function → press it":"Combo → name it"}${st.dir==="play"?(st.input==="hold"?", hold together":", tap then check"):""}. ${b[4]} of ${p.length} known.</span></span>
|
||||
<span class="mini" aria-hidden="true">${b.map(v=>`<div style="height:${Math.max(2,v/mx*22)}px"></div>`).join("")}</span><span aria-hidden="true">⚙</span></button>`;
|
||||
const e=st.card,info=$("info");
|
||||
if(!e){info.innerHTML="<div>No cards in this deck.</div>";lcd("DRILL","—","");return}
|
||||
const ctx=e.ctx.map(x=>CTX[x]).join(", ");
|
||||
const m=mobile();
|
||||
if(st.dir==="play"){
|
||||
if(!st.answered)st.chord.forEach(h=>KEY[h]&&KEY[h].classList.add("chord"));
|
||||
if(st.answered){
|
||||
e.mods.forEach(x=>KEY[x].classList.add("good"));
|
||||
if(e.tset.length<=4)e.tset.forEach(t=>KEY[t].classList.add("good"));else e.tset.forEach(t=>KEY[t].classList.add("target"));
|
||||
st.chord.forEach(h=>{if(!e.mods.includes(h)&&!e.tset.includes(h))KEY[h].classList.add("miss")});
|
||||
lcd(ctx.toUpperCase(),e.name,st.lastOk?"correct":"not quite");
|
||||
info.innerHTML=`<div class="ihead"><div><div class="fb ${st.lastOk?"ok":"no"}">${st.lastOk?"Correct.":"Not quite."}</div><div>${combo(e,false)}</div></div><div class="ihr"><button class="btn" data-next>Next</button></div></div>${e.desc?`<div class="idesc ${m?"clamp":""}">${esc(e.desc)}</div>`:""}
|
||||
<div class="muted" style="font-size:.8rem">${e.how?esc(e.how)+". ":""}p.${e.page}${m?". Tap the panel for the next card.":""}</div>`;
|
||||
}else{
|
||||
lcd(ctx.toUpperCase(),e.name,st.input==="hold"?"hold the combo":"tap the controls");
|
||||
const chordLine=st.chord.length?caps(st.chord.map(mName)):`<span class="muted">${st.input==="hold"?"Press every control of the combo together, then let go. It checks on release.":"Tap every control involved, in any order, then check."}</span>`;
|
||||
info.innerHTML=`<div class="ihead"><div><div class="muted" style="font-size:.82rem">${esc(ctx)}</div><div class="ititle">${esc(e.name)}</div></div><div class="ihr"><button class="btn ghost sm" data-show>Show me</button></div></div><div>${chordLine}</div>
|
||||
${st.input==="tap"?`<div class="irow"><button class="btn sm" data-check ${st.chord.length?"":"disabled"}>Check</button><button class="btn ghost sm" data-clear>Clear</button></div>`:""}`;
|
||||
}
|
||||
}else{
|
||||
e.mods.forEach(x=>KEY[x].classList.add(st.answered?"good":"held"));
|
||||
e.tset.forEach(t=>KEY[t].classList.add(st.answered&&e.tset.length<=4?"good":"target"));
|
||||
if(!st.opts){
|
||||
const others=ENTRIES.filter(x=>x.name!==e.name),near=others.filter(x=>x.ctx.some(c=>e.ctx.includes(c)));
|
||||
const src=(near.length>=3?near:others).sort(()=>Math.random()-.5),names=[];
|
||||
for(const x of src){if(!names.includes(x.name))names.push(x.name);if(names.length===3)break}
|
||||
st.opts=[...names,e.name].sort(()=>Math.random()-.5);
|
||||
}
|
||||
lcd(ctx.toUpperCase(),st.answered?e.short:"?",[...e.mods.map(mName),tName(e.target)].join(" + "));
|
||||
if(st.answered){
|
||||
info.innerHTML=`<div class="ihead"><div><div class="fb ${st.lastOk?"ok":"no"}">${st.lastOk?"Correct.":"Not quite."}</div><div class="ititle">${esc(e.name)}</div></div><div class="ihr"><button class="btn" data-next>Next</button></div></div>${e.desc?`<div class="idesc ${m?"clamp":""}">${esc(e.desc)}</div>`:""}
|
||||
<div class="muted" style="font-size:.8rem">${combo(e,false)} p.${e.page}</div>`;
|
||||
}else{
|
||||
info.innerHTML=`<div class="muted" style="font-size:.82rem">${esc(ctx)}. What does the lit combo do?</div>${m?"":`<div>${combo(e)}</div>`}<div class="opts">${st.opts.map((o,i)=>`<button class="opt" data-opt="${i}">${esc(o)}</button>`).join("")}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
function settingsHTML(){
|
||||
const b=dist(),n=pool().length;
|
||||
const chips=(attr,items,cur)=>`<div class="chips">${items.map(([k,l])=>`<button class="chip" data-${attr}="${k}" aria-pressed="${k===cur}">${esc(l)}</button>`).join("")}</div>`;
|
||||
return `<div class="lbl">Deck</div>${chips("deck",Object.keys(DECKS).map(k=>[k,DECKS[k].n]),st.deck)}
|
||||
<div class="lbl">Direction</div>${chips("dir",[["play","Function → press it"],["name","Combo → name it"]],st.dir)}
|
||||
<div class="lbl">How you answer “press it” cards</div>${chips("input",[["hold","Hold together (multitouch)"],["tap","Tap each, then check"]],st.input)}
|
||||
<div class="lbl">This deck</div><div>${b[4]} of ${n} known, ${b[0]} new or missed.</div>
|
||||
<div class="irow"><button class="btn ghost" data-reset>Reset all progress</button></div>
|
||||
<div class="lbl">Backup (progress lives in this browser only)</div>
|
||||
<textarea id="bk" spellcheck="false">${esc(JSON.stringify(store))}</textarea>
|
||||
<div class="irow"><button class="btn ghost" data-copybk>Copy</button><button class="btn ghost" data-loadbk>Load pasted backup</button><span id="bkmsg" class="muted" style="font-size:.85rem"></span></div>`;
|
||||
}
|
||||
|
||||
/* ============ find / rules ============ */
|
||||
function renderFind(){
|
||||
if(!$("q")){$("strip").innerHTML=`<input type="search" id="q" placeholder="undo, chop, metronome, side chain…" aria-label="Search functions" enterkeyhint="search">`;$("q").addEventListener("input",renderFindResults)}
|
||||
renderFindResults();
|
||||
}
|
||||
function renderFindResults(){
|
||||
const q=$("q").value.trim().toLowerCase();
|
||||
const res=q?ENTRIES.filter(e=>(e.name+" "+e.short+" "+e.desc+" "+e.mods.map(mName).join(" ")+" "+tName(e.target)+" "+e.ctx.map(c=>CTX[c]).join(" ")).toLowerCase().includes(q)):[];
|
||||
$("pane").innerHTML=q?(res.length?`<div class="list">${res.map(e=>`<button class="item" data-go="${e.i}"><span class="nm">${esc(e.name)}<div class="muted" style="font-size:.78rem;font-weight:400">${esc(e.ctx.map(c=>CTX[c]).join(", "))}</div></span><span class="combo">${combo(e,false)}</span><span class="pg">p.${e.page}</span></button>`).join("")}</div>`:`<p class="muted">No match. Try the name as printed on screen (CHOP, TR-REC, MUTE BUS).</p>`):`<p class="muted">Search by function name or by button. Tap a result to see it lit on the panel.</p>`;
|
||||
}
|
||||
const PADMAP=[["Row 1: how pads play",["Fixed vel","16 vel","Cue","Chromatic"]],["Row 2: organise pads",["Exchange","Init","Link","Mute group"]],["Row 3: time and input",["Metronome","Count-in","Tempo / tap","Gain"]],["Row 4: menus",["Utility","Import/export","Pad set","EFX set"]]];
|
||||
const RULES=`<div class="rules">
|
||||
<div class="rule"><h3>SHIFT + pad is a 4×4 map, row by row</h3><p>Learn the row themes first; the individual pads fall out of them.</p>
|
||||
<div class="grid4">${PADMAP.map(([r,a],ri)=>`<div class="rowlbl">${r}</div>`+a.map((x,i)=>`<div><b>${ri*4+i+1}</b>${x}</div>`).join("")).join("")}</div></div>
|
||||
<div class="rule"><h3>SHIFT + a playback button = the same thing, bigger or weirder</h3><p>GATE and BPM SYNC apply to the whole bank. LOOP becomes ping-pong. ROLL sets its rate. HOLD pauses. EXIT stops everything. REC in pattern mode becomes TR-REC.</p></div>
|
||||
<div class="rule"><h3>SHIFT + an edit button = its sibling screen</h3><p>START/END → markers and chop. PITCH/SPEED → envelope. RESAMPLE → merge. RECORD SETTING → synth. SUB PAD → projects. PATTERN SELECT → undo.</p></div>
|
||||
<div class="rule"><h3>REMAIN means “where does it go, and keep it there”</h3><p>With pads, banks or EXT SOURCE it sets bus routing. With an FX button it pins the screen. On edit screens it applies knob moves to the whole bank. Alone it shows pad info.</p></div>
|
||||
<div class="rule"><h3>Holding VALUE means “quietly, or only for now”</h3><p>Pads select without sound, FX buttons turn momentary, knobs reach sub-parameters, BUS FX bypasses 3/4 for a moment. Pressing VALUE on any edit screen opens its hidden menu.</p></div>
|
||||
<div class="rule"><h3>DEL or COPY + EXIT acts on a whole bank</h3><p>DEL + EXIT deletes a bank, COPY + EXIT copies it. COPY on edit screens scopes changes to the mute group instead of the bank.</p></div>
|
||||
<div class="rule"><h3>Three-second holds guard things you do not want by accident</h3><p>SHIFT + REMAIN (live mode) and SHIFT + MARK (save BUS 1/2 effects).</p></div>
|
||||
<div class="rule"><h3>Pattern mode reuses keys by meaning</h3><p>HOLD + pad = chains. SUB PAD + pad = switch now. MARK while recording = FX motion; SHIFT + MARK erases it. DEL while recording = erase notes.</p></div>
|
||||
<div class="rule"><h3>Lost? EXIT four times.</h3><p>Stops everything and clears pad mutes. EXIT alone backs out one screen.</p></div></div>`;
|
||||
|
||||
/* ============ render ============ */
|
||||
function render(){
|
||||
document.body.dataset.tab=st.tab;
|
||||
document.querySelectorAll("[data-tab]").forEach(x=>{if(x.tagName==="BUTTON")x.setAttribute("aria-selected",x.dataset.tab===st.tab)});
|
||||
if(st.tab!=="find"&&$("q"))$("strip").innerHTML="";
|
||||
if(st.tab==="explore")renderExplore();
|
||||
else if(st.tab==="drill"){$("pane").innerHTML="";renderDrill()}
|
||||
else if(st.tab==="find"){clearPanel();renderFind()}
|
||||
else{clearPanel();$("strip").innerHTML="";$("pane").innerHTML=`<header class="onlym"><h1 style="font-family:var(--fc);margin:6px 0 2px;font-size:1.7rem">SP-404MK2 layers</h1><p class="muted" style="margin:0 0 14px">From reference manual v5.50.</p></header>`+RULES}
|
||||
}
|
||||
|
||||
/* ============ pointer input (multitouch) ============ */
|
||||
const active=new Map(); // pointerId -> control id (null = swallowed)
|
||||
const pressedEl=new Map();
|
||||
const downAt={},used={},pendingRelease=new Set();
|
||||
let gesture=new Set();
|
||||
function exDown(id){
|
||||
if(st.held.includes(id)){pendingRelease.add(id);return}
|
||||
const hit=layerEntries().filter(e=>e.tset.includes(id));
|
||||
if(hit.length){const i=hit.indexOf(st.sel);st.sel=hit[(i+1)%hit.length];
|
||||
for(const v of active.values())if(v&&st.held.includes(v))used[v]=true;
|
||||
renderExplore();return}
|
||||
if(!NOLATCH.has(id)){st.held.push(id);downAt[id]=performance.now();used[id]=false;st.sel=null;renderExplore();return}
|
||||
st.sel=null;renderExplore();lcd(L[id],"—","nothing on this layer");
|
||||
}
|
||||
function exUp(id){
|
||||
if(pendingRelease.has(id)){pendingRelease.delete(id);st.held=st.held.filter(h=>h!==id);renderExplore();return}
|
||||
if(downAt[id]!=null){const dur=performance.now()-downAt[id];delete downAt[id];
|
||||
if(used[id]||dur>350){st.held=st.held.filter(h=>h!==id);renderExplore()}}
|
||||
}
|
||||
function drDown(id,pid){
|
||||
if(st.answered){active.set(pid,null);nextCard();return}
|
||||
if(st.dir!=="play")return;
|
||||
if(st.input==="hold"){gesture.add(id);st.chord=[...gesture];renderDrill()}
|
||||
else{st.chord=st.chord.includes(id)?st.chord.filter(x=>x!==id):[...st.chord,id];renderDrill()}
|
||||
}
|
||||
function drUp(){
|
||||
if(st.dir!=="play"||st.input!=="hold"||st.answered)return;
|
||||
if([...active.values()].some(v=>v))return;
|
||||
if(!gesture.size)return;
|
||||
const ch=[...gesture];gesture=new Set();st.chord=ch;grade(isCorrect(ch));renderDrill();
|
||||
}
|
||||
const unit=$("unit");
|
||||
unit.addEventListener("pointerdown",ev=>{
|
||||
const b=ev.target.closest(".k");if(!b)return;
|
||||
ev.preventDefault();
|
||||
const id=b.dataset.id;active.set(ev.pointerId,id);pressedEl.set(ev.pointerId,b);b.classList.add("down");buzz();
|
||||
if(st.tab==="explore")exDown(id);else if(st.tab==="drill")drDown(id,ev.pointerId);
|
||||
});
|
||||
function onUp(ev){
|
||||
if(!active.has(ev.pointerId))return;
|
||||
const id=active.get(ev.pointerId);active.delete(ev.pointerId);
|
||||
const el=pressedEl.get(ev.pointerId);pressedEl.delete(ev.pointerId);if(el)el.classList.remove("down");
|
||||
if(!id)return;
|
||||
if(st.tab==="explore")exUp(id);else if(st.tab==="drill")drUp();
|
||||
}
|
||||
addEventListener("pointerup",onUp);addEventListener("pointercancel",onUp);
|
||||
unit.addEventListener("contextmenu",e=>e.preventDefault());
|
||||
unit.addEventListener("keydown",ev=>{
|
||||
if(ev.key!=="Enter"&&ev.key!==" ")return;const b=ev.target.closest(".k");if(!b)return;ev.preventDefault();
|
||||
const id=b.dataset.id,pid="kb"+id;
|
||||
if(st.tab==="explore"){exDown(id);exUp(id);}
|
||||
else if(st.tab==="drill"){ if(st.answered){nextCard();return} if(st.dir==="play"){ if(st.input==="hold"){st.input="tap"} st.chord=st.chord.includes(id)?st.chord.filter(x=>x!==id):[...st.chord,id];renderDrill()} }
|
||||
});
|
||||
|
||||
/* ============ clicks outside the panel ============ */
|
||||
document.addEventListener("click",ev=>{
|
||||
const t=ev.target.closest("button,[data-e]");if(!t||t.closest("#unit"))return;
|
||||
const d=t.dataset;
|
||||
if(d.tab){st.tab=d.tab;closeSheet();render();scrollTo(0,0);return}
|
||||
if(d.ctx){st.ctx=d.ctx;store.ctx=st.ctx;save();st.held=[];st.sel=null;renderExplore();return}
|
||||
if(d.layer!==undefined){st.held=d.layer?d.layer.split("+"):[];Object.keys(downAt).forEach(k=>delete downAt[k]);st.sel=null;renderExplore();return}
|
||||
if(d.e){st.sel=ENTRIES[+d.e];closeSheet();renderExplore();return}
|
||||
if(d.release!==undefined){st.held=[];renderExplore();return}
|
||||
if(d.openlist!==undefined){openSheet(`${st.held.length?st.held.map(mName).join(" + "):"No modifier"} in ${CTX[st.ctx]}`,listHTML(layerEntries()));return}
|
||||
if(d.more){const e=ENTRIES[+d.more];openSheet(e.name,`<div style="margin:10px 0">${combo(e)}</div><p>${esc(e.desc)}</p><p class="muted">${esc(e.ctx.map(c=>CTX[c]).join(", "))}. Manual p. ${e.page}.</p>`);return}
|
||||
if(d.go){const e=ENTRIES[+d.go];st.ctx=e.ctx[0];st.held=[...e.mods];st.sel=e;st.tab="explore";render();scrollTo(0,0);return}
|
||||
if(d.next!==undefined){nextCard();return}
|
||||
if(d.check!==undefined){grade(isCorrect(st.chord));renderDrill();return}
|
||||
if(d.clear!==undefined){st.chord=[];renderDrill();return}
|
||||
if(d.show!==undefined){grade(false);renderDrill();return}
|
||||
if(d.opt){st.picked=st.opts[+d.opt];grade(st.picked===st.card.name);renderDrill();return}
|
||||
if(d.settings!==undefined){openSheet("Drill settings",settingsHTML());return}
|
||||
const resettings=()=>{$("sbody").innerHTML=settingsHTML();renderDrill()};
|
||||
if(d.deck){st.deck=d.deck;store.deck=st.deck;save();st.card=null;resettings();return}
|
||||
if(d.dir){st.dir=d.dir;store.dir=st.dir;save();st.card=null;resettings();return}
|
||||
if(d.input){st.input=d.input;store.input=st.input;save();st.chord=[];resettings();return}
|
||||
if(d.reset!==undefined){if(confirm("Reset all drill progress?")){store.boxes={};save();st.card=null;resettings()}return}
|
||||
if(d.copybk!==undefined){const ta=$("bk");ta.select();let ok=false;try{navigator.clipboard.writeText(ta.value);ok=true}catch(e){try{ok=document.execCommand("copy")}catch(_){}}$("bkmsg").textContent=ok?"Copied.":"Select the text and copy it manually.";return}
|
||||
if(d.loadbk!==undefined){try{const o=JSON.parse($("bk").value);if(!o||typeof o.boxes!=="object")throw 0;store=o;save();st.deck=store.deck||st.deck;st.dir=store.dir||st.dir;st.card=null;resettings();$("bkmsg").textContent="Backup loaded."}catch(e){$("bkmsg").textContent="That is not a valid backup."}return}
|
||||
});
|
||||
matchMedia("(max-width:820px)").addEventListener("change",()=>render());
|
||||
render();
|
||||
</script>
|
||||
<script>if("serviceWorker" in navigator&&location.protocol!=="file:")addEventListener("load",()=>navigator.serviceWorker.register("sw.js"));</script>
|
||||
</body>
|
||||
</html>
|
||||
29
reference/manifest.webmanifest
Normal file
29
reference/manifest.webmanifest
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "SP-404MK2 layers",
|
||||
"short_name": "404 layers",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#151719",
|
||||
"theme_color": "#2a2d30",
|
||||
"description": "Combo trainer for the Roland SP-404MK2",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
reference/sw.js
Normal file
14
reference/sw.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const V="404layers-v2";
|
||||
const FILES=["./", "index.html", "manifest.webmanifest", "icons/icon-192.png", "icons/icon-512.png", "icons/maskable-512.png", "fonts/barlow-condensed-600.woff2", "fonts/barlow-400.woff2", "fonts/barlow-condensed-700.woff2", "fonts/barlow-500.woff2", "fonts/barlow-condensed-500.woff2", "fonts/barlow-600.woff2"];
|
||||
self.addEventListener("install",e=>e.waitUntil(caches.open(V).then(c=>c.addAll(FILES)).then(()=>self.skipWaiting())));
|
||||
self.addEventListener("activate",e=>e.waitUntil(caches.keys().then(ks=>Promise.all(ks.filter(k=>k!==V).map(k=>caches.delete(k)))).then(()=>self.clients.claim())));
|
||||
self.addEventListener("fetch",e=>{
|
||||
if(e.request.method!=="GET")return;
|
||||
const url=new URL(e.request.url);if(url.origin!==location.origin)return;
|
||||
// network first for the page (so updates land), cache first for everything else
|
||||
if(e.request.mode==="navigate"){
|
||||
e.respondWith(fetch(e.request).then(r=>{const c=r.clone();caches.open(V).then(x=>x.put("index.html",c));return r}).catch(()=>caches.match("index.html")));
|
||||
return;
|
||||
}
|
||||
e.respondWith(caches.match(e.request).then(r=>r||fetch(e.request)));
|
||||
});
|
||||
Reference in New Issue
Block a user