• 01 - CSS: Insects
  • 02 - Critter Models 02
  • 03 - VantaJS Shaders
  • 04 - App Bundle 02
  • 05 - Cathedrals
  • 06 - Board Games
  • 07 - App Bundle 01
  • 08 - Retroscopic
  • 09 - Games 26
  • 10 - Voronoi
  • 11 - Terrain
  • 12 - Critter Models 01
  • 13 - Chromatophores
  • 14 - Conformal Maps
  • 15 - Echo Logix
  • 16 - Music 26
  • 17 - Colour Theory
ARCHIVE
Notes
Number:
Project:
01CSS: Insects
└ CSS Beetle└ CSS Ladybird└ CSS Bluebottle└ CSS Dragonfly
02Critter Models 02
└ Locust└ Butterfly└ Cockroach└ Spider└ Scorpion└ Dragon└ Brazilian Treehopper└ Tardigrade└ Diatoms└ Bumblebee└ Wasp└ Hammerhead Worm└ Giraffe Weevil└ Trilobite Beetle
03VantaJS Shaders
└ Clouds└ Waves└ Cells└ Rings└ Halo└ Globe└ Net└ Dots└ Birds└ Trunk└ Topology└ About VantaJS
04App Bundle 02
└ OwnZones└ Gutindex└ OpenLayers└ SH-101└ Pip└ Great White
05Cathedrals
└ Lattice└ Lost└ Lucent└ Lumen└ Glass└ Disco└ Hyperion└ Latent
06Board Games
└ Chess└ Carrom└ Connect 4
07App Bundle 01
└ Robohash└ PrismJS└ Horoscope└ Binary Jazz└ Morse Code└ URL Extractor└ Entity Escaper└ Phonetic Alphabet└ Toki Pona└ Semaphore└ Cut Up Machine└ Text Mixer└ Currency Freaks└ Host IO└ CrUX API└ Logo Dev└ Web Speech API└ GEO DB
01 / 03

SH 101 - INTERACTIVE APP SOURCE CODE

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>SH-101</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=B612:wght@400;700&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=B612+Mono:wght@400;700&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Michroma&display=swap">
<style>
:root{
  box-sizing:border-box;
  padding-top:env(safe-area-inset-top,0px);
  padding-bottom:env(safe-area-inset-bottom,0px);

  --f-ui:'B612','Segoe UI','Helvetica Neue',Arial,sans-serif;
  --f-disp:'B612 Mono',ui-monospace,'SF Mono',Menlo,Consolas,monospace;
  --f-badge:'Michroma','Eurostile','Trebuchet MS',Arial,sans-serif;

  /* the room the instrument sits in */
  --page:#e2e4e3;
  --page-2:#c9cccb;
  --page-ink:#1c1f22;
  --page-dim:#565b60;

  /* powder-coated chassis */
  --body:#202224;
  --body-hi:#303235;
  --body-lo:#141516;
  --body-dim:#8e9396;
  --deck:#1a1b1d;

  /* panel finish: brushed aluminium (default) */
  --metal:#c3c6c4;
  --metal-hi:#dcdedc;
  --metal-lo:#a5a8a7;
  --sheen:.2;
  --veil:transparent;
  --print:#16181a;
  --print-soft:#474b4f;
  --knock:#d0d3d1;
  --cap-face:#2c2e31;
  --cap-rib:#1a1c1e;
  --cap-line:#f0f1ed;
  --track:#0d0e0f;

  /* things that glow */
  --glass:#140e07;
  --vfd:#ffae2b;
  --vfd-soft:#b07722;
  --led:#ff3324;
  --led-off:#4f1712;

  /* sizing */
  --fw:28px; --fh:136px; --caph:20px; --g:6px;
  --sw-lh:13px; --sw-pitch:19px;
  --cheek:18px;

  --brush:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='240'%3E%3Cfilter id='b' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.0025 .9' numOctaves='2' seed='11' stitchTiles='stitch'/%3E%3CfeColorMatrix values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 .2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23b)'/%3E%3C/svg%3E");
  --wood:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='640' viewBox='0 0 40 640' preserveAspectRatio='none'%3E%3Cfilter id='w' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.17 .0045' numOctaves='3' seed='5'/%3E%3CfeColorMatrix values='0 0 0 0 .16 0 0 0 0 .085 0 0 0 0 .04 2.5 0 0 0 -.95'/%3E%3C/filter%3E%3Crect width='40' height='640' fill='%23774628'/%3E%3Crect width='40' height='640' filter='url(%23w)'/%3E%3C/svg%3E");
}
html[data-model="champagne"]{
  --metal:#c8b68f; --metal-hi:#ddcea9; --metal-lo:#ab9870; --sheen:.18;
  --print:#1b1813; --print-soft:#4a4334; --knock:#dccba7;
}
html[data-model="black"]{
  --metal:#2d2f31; --metal-hi:#3b3d40; --metal-lo:#232527; --sheen:.06; --veil:rgba(44,46,48,.6);
  --print:#e2e4e1; --print-soft:#9a9ea0; --knock:#26282a;
  --cap-face:#d2d5d3; --cap-rib:#adb0ae; --cap-line:#16181a; --track:#08090a;
}

@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]){ --page:#1d1e1f; --page-2:#0f1011; --page-ink:#e3e5e3; --page-dim:#999ea1; }
}
:root[data-theme="dark"]{ --page:#1d1e1f; --page-2:#0f1011; --page-ink:#e3e5e3; --page-dim:#999ea1; }

*,*::before,*::after{box-sizing:inherit}
html{min-height:100%;scroll-padding-top:env(safe-area-inset-top,0px);background:var(--page-2)}
body{
  margin:0;min-height:100%;
  padding:26px 12px 52px;
  background:radial-gradient(130% 85% at 50% 0%,var(--page) 0%,var(--page-2) 100%);
  color:var(--page-ink);
  font-family:var(--f-ui);
  -webkit-text-size-adjust:100%;
  overscroll-behavior-y:none;
}
button,select{font:inherit;color:inherit}
@media (prefers-reduced-motion: reduce){ *{animation:none!important;transition:none!important} }

/* ---------- the instrument as an object ---------- */
.stage{max-width:1380px;margin:0 auto}
.instrument{
  position:relative;display:flex;align-items:stretch;border-radius:12px;
  box-shadow:0 44px 64px -30px rgba(0,0,0,.62),0 20px 30px -16px rgba(0,0,0,.42);
}
.cheek{
  flex:0 0 var(--cheek);
  background:
    linear-gradient(90deg,rgba(0,0,0,.5) 0%,rgba(255,230,200,.13) 36%,rgba(255,230,200,.05) 62%,rgba(0,0,0,.55) 100%),
    var(--wood) center/100% 100% no-repeat,
    #774628;
}
.cheek-l{border-radius:12px 0 0 12px;box-shadow:inset 1px 1px 0 rgba(255,235,210,.18),inset -1px 0 0 rgba(0,0,0,.65)}
.cheek-r{border-radius:0 12px 12px 0;box-shadow:inset -1px 1px 0 rgba(255,235,210,.12),inset 1px 0 0 rgba(0,0,0,.65)}
.chassis{
  position:relative;flex:1 1 auto;min-width:0;padding:12px 10px;
  background:
    linear-gradient(180deg,rgba(255,255,255,.08),rgba(255,255,255,0) 22px),
    linear-gradient(180deg,var(--body-hi),var(--body) 44px,var(--body) calc(100% - 26px),var(--body-lo));
  box-shadow:inset 0 1px 0 rgba(255,255,255,.12),inset 0 -2px 0 rgba(0,0,0,.45);
}
.screw{
  position:absolute;width:7px;height:7px;border-radius:50%;
  background:radial-gradient(circle at 35% 32%,#a3a7a9,#5b5f62 55%,#2c2e30);
  box-shadow:0 1px 0 rgba(255,255,255,.07),0 0 0 1px rgba(0,0,0,.5)
}
.screw::after{content:"";position:absolute;left:1px;right:1px;top:3px;height:1px;background:rgba(0,0,0,.75);transform:rotate(var(--r,30deg))}
.screw.tl{top:3px;left:3px;--r:28deg}
.screw.tr{top:3px;right:3px;--r:-52deg}
.screw.bl{bottom:3px;left:3px;--r:71deg}
.screw.br{bottom:3px;right:3px;--r:12deg}

/* ---------- header strip ---------- */
.header{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:flex-end;gap:12px 24px;padding:4px 8px 14px}
.badge-wrap{display:flex;align-items:center;gap:14px;margin-right:auto}
.badge{
  display:inline-flex;align-items:center;padding:8px 11px 7px 18px;border-radius:4px;
  background:linear-gradient(180deg,#0e0f10,#1b1c1e 55%,#111213);
  box-shadow:0 0 0 1px #000,0 0 0 2.5px #9da1a3,0 0 0 3.5px #0a0b0c,0 4px 8px rgba(0,0,0,.65),inset 0 1px 0 rgba(255,255,255,.08);
}
.wm{font-family:var(--f-badge);font-size:21px;line-height:1;letter-spacing:.34em;color:#d6d9d7}
@supports (-webkit-background-clip:text) or (background-clip:text){
  .wm{
    background:linear-gradient(180deg,#ffffff 0%,#dfe2e0 34%,#7b7f82 50%,#eceeec 66%,#9ea2a4 100%);
    -webkit-background-clip:text;background-clip:text;color:transparent;
  }
}
.subtitle{font-size:10.5px;line-height:1.25;font-weight:700;color:var(--body-dim);max-width:8.5em}

.screen{
  position:relative;flex:0 0 auto;width:200px;height:52px;padding:3px;border-radius:5px;background:#08090a;
  box-shadow:0 0 0 1px #000,0 1px 0 1px rgba(255,255,255,.07),inset 0 2px 5px rgba(0,0,0,.9);
}
.screen canvas{display:block;width:100%;height:100%;border-radius:3px;background:var(--glass)}
.screen::after{
  content:"";position:absolute;inset:3px;border-radius:3px;pointer-events:none;
  background:
    linear-gradient(162deg,rgba(255,255,255,.1),rgba(255,255,255,0) 42%),
    repeating-linear-gradient(180deg,rgba(0,0,0,.28) 0 1px,rgba(0,0,0,0) 1px 3px);
}

.hgrp{display:flex;flex-direction:column;gap:6px}
.hlbl{font-size:10px;line-height:1;font-weight:700;color:var(--body-dim)}
.hrow{display:flex;align-items:center;gap:6px}

select.sel{
  -webkit-appearance:none;appearance:none;width:178px;max-width:100%;min-width:0;
  font-family:var(--f-disp);font-size:13px;line-height:1.2;font-weight:700;color:var(--vfd);
  padding:6px 26px 5px 9px;border:0;border-radius:3px;cursor:pointer;
  background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23ffae2b' stroke-width='1.6'/%3E%3C/svg%3E") no-repeat right 9px center/10px 6px,var(--glass);
  box-shadow:inset 0 2px 5px rgba(0,0,0,.9),0 0 0 1px #000,0 1px 0 1px rgba(255,255,255,.06);
  text-shadow:0 0 6px rgba(255,174,43,.5);
}
select.sel:focus-visible{outline:2px solid var(--vfd);outline-offset:3px}
select.sel option{background:#1a140c;color:#ffd08a;text-shadow:none}

.swatches{display:flex;gap:8px;padding:2px 0 3px}
.swatch{
  width:18px;height:18px;border-radius:50%;padding:0;border:0;cursor:pointer;
  box-shadow:0 0 0 1px #000,inset 0 1px 1px rgba(255,255,255,.5),inset 0 -2px 3px rgba(0,0,0,.35)
}
.swatch[aria-pressed="true"]{box-shadow:0 0 0 1px #000,0 0 0 3px var(--vfd),inset 0 1px 1px rgba(255,255,255,.5),inset 0 -2px 3px rgba(0,0,0,.35)}
.swatch:focus-visible{outline:2px solid var(--vfd);outline-offset:4px}
.swatch.silver{background:linear-gradient(135deg,#e2e4e2,#a9acab)}
.swatch.champagne{background:linear-gradient(135deg,#e3d4b0,#ae9b73)}
.swatch.black{background:linear-gradient(135deg,#4a4c4f,#1f2123)}

.pwr{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:0;border:0;background:none;cursor:pointer}
.pwr:focus-visible{outline:2px solid var(--vfd);outline-offset:4px;border-radius:3px}
.pw-row{display:flex;align-items:center;gap:9px}
.rocker{display:block;width:36px;height:22px;padding:2px;border-radius:3px;background:#060707;box-shadow:inset 0 1px 3px #000,0 1px 0 rgba(255,255,255,.08)}
.rocker i{
  position:relative;display:block;height:100%;border-radius:2px;
  background:linear-gradient(90deg,#4a4d50 0%,#2b2d2f 47%,#18191a 53%,#212325 100%);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.12)
}
.pwr[data-on="1"] .rocker i{background:linear-gradient(90deg,#212325 0%,#18191a 47%,#2b2d2f 53%,#4a4d50 100%)}
.rocker i::before{content:"";position:absolute;left:7px;top:50%;width:2px;height:8px;margin-top:-4px;background:#d9dcda;opacity:.75}
.rocker i::after{content:"";position:absolute;right:6px;top:50%;width:7px;height:7px;margin-top:-3.5px;border:1.5px solid #d9dcda;border-radius:50%;opacity:.5}
.pilot{
  width:11px;height:11px;border-radius:50%;
  background:radial-gradient(circle at 40% 35%,#8a2a22,var(--led-off) 70%);
  box-shadow:0 0 0 2px #0b0c0d,0 0 0 3px rgba(205,208,206,.35)
}
.pwr[data-on="1"] .pilot{
  background:radial-gradient(circle at 40% 35%,#ffd7cf,var(--led) 45%,#b3160c);
  box-shadow:0 0 0 2px #0b0c0d,0 0 0 3px rgba(205,208,206,.35),0 0 10px 2px rgba(255,51,36,.7)
}

/* ---------- silkscreened control panel ---------- */
.panel{
  display:flex;flex-wrap:wrap;align-items:stretch;border-radius:4px;overflow:hidden;
  background:
    linear-gradient(102deg,rgba(255,255,255,0) 14%,rgba(255,255,255,var(--sheen)) 40%,rgba(255,255,255,0) 64%),
    linear-gradient(var(--veil),var(--veil)),
    var(--brush) 0 0/640px 240px,
    linear-gradient(180deg,var(--metal-hi),var(--metal) 42%,var(--metal-lo));
  box-shadow:0 0 0 1px #000,0 1px 0 1px rgba(255,255,255,.07),inset 0 1px 0 rgba(255,255,255,.4),inset 0 -1px 0 rgba(0,0,0,.25);
}
.sec{
  flex:1 1 auto;display:flex;flex-direction:column;
  margin:-1px 0 0 -1px;border-left:1px solid var(--print);border-top:1px solid var(--print)
}
.sec > h2{
  margin:0;padding:5px 10px 4px;background:var(--print);color:var(--knock);
  font-size:11px;line-height:1.15;font-weight:700;letter-spacing:.015em;white-space:nowrap
}
.row{flex:1 1 auto;display:flex;align-items:flex-end;justify-content:space-evenly;gap:var(--g);padding:12px 8px 8px}

.lbl{width:min-content;min-height:2.3em;font-size:9.5px;line-height:1.15;font-weight:700;color:var(--print);text-align:center}

/* faders */
.ctrl{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:var(--fw)}
.fader{position:relative;width:var(--fw);height:var(--fh);touch-action:none;cursor:ns-resize;border-radius:3px}
.fader:focus-visible{outline:2px solid var(--print);outline-offset:3px}
.fader .track{
  position:absolute;z-index:1;left:50%;width:5px;margin-left:-2.5px;
  top:calc(var(--caph) / 2 - 5px);bottom:calc(var(--caph) / 2 - 5px);
  border-radius:3px;background:var(--track);
  box-shadow:inset 0 1px 2px rgba(0,0,0,.9),0 1px 0 rgba(255,255,255,.4),0 -1px 0 rgba(0,0,0,.25)
}
.fader .ticks{position:absolute;left:0;right:0;top:calc(var(--caph) / 2);bottom:calc(var(--caph) / 2);pointer-events:none}
.fader .ticks i{position:absolute;left:6px;right:6px;height:1px;margin-top:-.5px;background:var(--print);opacity:.45}
.fader .ticks i.maj{left:1px;right:1px;opacity:.85}
.fader .cap{
  position:absolute;z-index:2;left:50%;
  width:calc(var(--fw) - 4px);height:var(--caph);margin-left:calc((var(--fw) - 4px) / -2);
  border-radius:2px;border:1px solid rgba(0,0,0,.6);pointer-events:none;
  background:
    linear-gradient(180deg,rgba(255,255,255,.2),rgba(255,255,255,0) 30%,rgba(0,0,0,0) 70%,rgba(0,0,0,.3)),
    linear-gradient(180deg,transparent 38%,var(--cap-face) 38%,var(--cap-face) 62%,transparent 62%),
    repeating-linear-gradient(180deg,var(--cap-face) 0 2px,var(--cap-rib) 2px 3px);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.2),0 3px 0 -1px rgba(0,0,0,.35),0 5px 8px rgba(0,0,0,.42)
}
.fader .cap::after{content:"";position:absolute;left:2px;right:2px;top:50%;height:2px;margin-top:-1px;border-radius:1px;background:var(--cap-line)}
.fader .chip{
  position:absolute;z-index:8;left:50%;top:2px;transform:translateX(-50%);
  padding:3px 6px 2px;border-radius:3px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .09s;
  background:var(--glass);color:var(--vfd);font-family:var(--f-disp);font-size:11px;line-height:1;font-weight:700;
  text-shadow:0 0 6px rgba(255,174,43,.55);box-shadow:0 0 0 1px #000,0 3px 8px rgba(0,0,0,.5)
}
.fader[data-chip="low"] .chip{top:auto;bottom:2px}
.fader[data-act="1"] .chip,.fader:focus-visible .chip{opacity:1}

/* slide switches */
.swc{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:42px}
.swbox{height:var(--fh);display:flex;align-items:center}
.sw{display:flex;align-items:stretch;gap:5px;border-radius:3px}
.sw:focus-visible{outline:2px solid var(--print);outline-offset:4px}
.sw .rail{
  position:relative;flex:0 0 12px;width:12px;border-radius:3px;background:var(--track);
  box-shadow:inset 0 1px 3px rgba(0,0,0,.9),0 1px 0 rgba(255,255,255,.35)
}
.sw .lever{
  position:absolute;left:1px;width:10px;height:var(--sw-lh);border-radius:2px;transition:top .08s ease;
  background:repeating-linear-gradient(180deg,var(--cap-face) 0 2px,var(--cap-rib) 2px 3px);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.22),0 1px 2px rgba(0,0,0,.7)
}
.sw .opts{display:flex;flex-direction:column;gap:calc(var(--sw-pitch) - var(--sw-lh))}
.sw .opt{
  display:block;height:var(--sw-lh);padding:0;border:0;background:none;cursor:pointer;text-align:left;white-space:nowrap;
  font-size:9.5px;line-height:var(--sw-lh);font-weight:400;color:var(--print-soft)
}
.sw .opt[aria-checked="true"]{color:var(--print);font-weight:700}

/* LEDs */
.ledcol{display:flex;flex-direction:column;align-items:center;gap:6px}
.ledcol .swbox{align-items:flex-start;padding-top:calc(var(--caph) / 2 - 4px)}
.ledcol .lbl{min-width:10px}
.led{
  display:inline-block;flex:0 0 auto;width:8px;height:8px;border-radius:50%;
  background:radial-gradient(circle at 38% 34%,#86271d,var(--led-off) 72%);
  box-shadow:0 0 0 1.5px #0c0d0e,inset 0 1px 1px rgba(255,255,255,.16)
}
.led[data-on="1"]{
  background:radial-gradient(circle at 38% 34%,#ffdcd4,var(--led) 46%,#b5170c);
  box-shadow:0 0 0 1.5px #0c0d0e,0 0 8px 1px rgba(255,51,36,.75)
}

/* ---------- performance deck ---------- */
.deck{
  margin-top:12px;padding:10px 12px 12px;border-radius:5px;display:flex;flex-direction:column;gap:12px;
  background:linear-gradient(180deg,#18191b,#1e2022);
  box-shadow:inset 0 0 0 1px #000,inset 0 2px 6px rgba(0,0,0,.55),0 1px 0 rgba(255,255,255,.06)
}
.transport,.bank{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px 22px}
.bank{padding-top:12px;border-top:1px solid #000;box-shadow:inset 0 1px 0 rgba(255,255,255,.045)}
.grp{display:flex;flex-direction:column;gap:7px}
.gname{display:flex;align-items:center;gap:7px;font-size:9.5px;line-height:1;font-weight:700;color:var(--body-dim);white-space:nowrap}
.gname::before,.gname::after{content:"";flex:1 1 0;min-width:8px;height:6px;margin-top:6px;border-top:1px solid rgba(160,165,168,.5)}
.gname::before{border-left:1px solid rgba(160,165,168,.5)}
.gname::after{border-right:1px solid rgba(160,165,168,.5)}
.btns{display:flex;flex-wrap:wrap;align-items:center;gap:6px;min-height:34px}

.btn{
  display:inline-flex;align-items:center;gap:6px;padding:7px 10px 6px;border-radius:3px;border:1px solid #000;cursor:pointer;
  font-size:10.5px;line-height:1;font-weight:700;letter-spacing:.01em;color:#1b1d1f;white-space:nowrap;
  background:
    var(--brush) 0 0/320px 120px,
    linear-gradient(180deg,#e7e9e7,#c8cbc9 60%,#b4b7b5);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.75),inset 0 -1px 0 rgba(0,0,0,.18),0 2px 0 #4b4e50,0 3px 0 #000,0 5px 6px rgba(0,0,0,.5);
  transition:transform .05s,box-shadow .05s
}
.btn:active:not([disabled]){
  transform:translateY(2px);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.6),inset 0 -1px 0 rgba(0,0,0,.18),0 0 0 #4b4e50,0 1px 0 #000,0 2px 3px rgba(0,0,0,.45)
}
.btn[disabled]{opacity:.42;cursor:default}
.btn:focus-visible{outline:2px solid var(--vfd);outline-offset:3px}
.btn .led{width:7px;height:7px}
.btn-sm{padding:6px 8px 5px;font-size:10px}
.btn-sq{width:30px;padding:6px 0 5px;justify-content:center;font-size:14px}

.octval{
  min-width:30px;padding:7px 0 5px;border-radius:3px;text-align:center;background:var(--glass);
  font-family:var(--f-disp);font-size:13px;line-height:1;font-weight:700;color:var(--vfd);
  text-shadow:0 0 6px rgba(255,174,43,.55);box-shadow:inset 0 2px 4px rgba(0,0,0,.9),0 0 0 1px #000
}
.readout{
  flex:1 1 250px;align-self:flex-end;min-height:34px;padding:7px 10px;border-radius:3px;text-align:right;
  background:var(--glass);color:var(--vfd-soft);
  font-family:var(--f-disp);font-size:12px;line-height:20px;
  text-shadow:0 0 6px rgba(255,174,43,.3);
  box-shadow:inset 0 2px 6px rgba(0,0,0,.9),0 0 0 1px #000,0 1px 0 1px rgba(255,255,255,.05)
}
.readout b{font-weight:700;color:var(--vfd);text-shadow:0 0 8px rgba(255,174,43,.6)}

.strip{
  flex:1 1 100%;min-width:0;display:flex;align-items:center;gap:3px;overflow-x:auto;
  min-height:32px;padding:5px;border-radius:3px;background:var(--glass);
  box-shadow:inset 0 2px 6px rgba(0,0,0,.9),0 0 0 1px #000,0 1px 0 1px rgba(255,255,255,.05);
  scrollbar-width:thin;scrollbar-color:#5b3d12 transparent
}
.seqcell,.chaincell{
  flex:0 0 auto;min-width:27px;padding:3px 4px 2px;border-radius:2px;text-align:center;cursor:pointer;
  font-family:var(--f-disp);font-size:11.5px;line-height:1.1;color:var(--vfd-soft);
  background:rgba(255,174,43,.06);box-shadow:inset 0 0 0 1px rgba(255,174,43,.16)
}
.chaincell{min-width:24px;font-weight:700}
.seqcell[data-cur="1"],.chaincell[data-cur="1"]{background:var(--vfd);color:#1d1205;box-shadow:0 0 10px rgba(255,174,43,.55)}
.seqempty{padding:2px 4px;font-family:var(--f-disp);font-size:11.5px;line-height:1.3;color:var(--vfd-soft)}

.slots{display:flex;gap:5px}
.slot{
  width:34px;height:34px;padding:0;border-radius:3px;border:1px solid #000;cursor:pointer;
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;color:#8f9497;
  background:linear-gradient(180deg,#3a3c3f,#2a2c2e 70%,#232527);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.12),0 2px 0 #0b0c0d,0 4px 5px rgba(0,0,0,.5);
  transition:transform .05s
}
.slot .ltr{font-size:13px;line-height:1;font-weight:700}
.slot .ct{font-family:var(--f-disp);font-size:9px;line-height:1}
.slot[data-full="1"]{color:#e3e5e3}
.slot[data-full="1"] .ct{color:var(--vfd);text-shadow:0 0 5px rgba(255,174,43,.5)}
.slot[data-sel="1"]{box-shadow:inset 0 1px 0 rgba(255,255,255,.12),0 0 0 2px var(--vfd),0 2px 0 #0b0c0d,0 4px 5px rgba(0,0,0,.5)}
.slot:active{transform:translateY(1px)}
.slot:focus-visible{outline:2px solid var(--vfd);outline-offset:3px}

/* ---------- joystick and keys ---------- */
.lower{display:flex;align-items:stretch;gap:10px;margin-top:12px}
.grip{
  flex:0 0 118px;display:flex;flex-direction:column;gap:8px;padding:8px 9px 9px;border-radius:5px;
  background:linear-gradient(180deg,#18191b,#1e2022);
  box-shadow:inset 0 0 0 1px #000,inset 0 2px 6px rgba(0,0,0,.55),0 1px 0 rgba(255,255,255,.06)
}
.pad{
  position:relative;flex:1 1 auto;min-height:96px;border-radius:6px;overflow:hidden;touch-action:none;cursor:grab;
  background:radial-gradient(110% 110% at 50% 45%,#2b2d2f,#0d0e0f 72%);
  box-shadow:inset 0 3px 9px rgba(0,0,0,.95),inset 0 0 0 1px #000,0 1px 0 rgba(255,255,255,.07)
}
.pad:active{cursor:grabbing}
.pad:focus-visible{outline:2px solid var(--vfd);outline-offset:2px}
.pad .cross{position:absolute;inset:0;pointer-events:none}
.pad .cross i{position:absolute;background:rgba(160,165,168,.22)}
.pad .cross i.h{left:13px;right:13px;bottom:13px;height:1px}
.pad .cross i.v{top:13px;bottom:13px;left:50%;width:1px}
.pad .hint{position:absolute;top:6px;left:0;right:0;text-align:center;font-size:8.5px;line-height:1;font-weight:700;color:rgba(160,165,168,.55);pointer-events:none}
.pad .lever{
  position:absolute;left:50%;top:calc(100% - 13px);width:26px;height:26px;margin:-13px 0 0 -13px;border-radius:50%;pointer-events:none;
  background:radial-gradient(circle at 36% 30%,#ffffff 0%,#d9dcda 18%,#8a8e90 50%,#3a3c3e 78%,#1d1f20 100%);
  box-shadow:0 5px 8px rgba(0,0,0,.75),0 0 0 1px rgba(0,0,0,.7)
}

.kbwrap{
  position:relative;flex:1 1 auto;min-width:0;padding:0 5px 5px;border-radius:5px;
  background:linear-gradient(180deg,#0f1011,#18191b);
  box-shadow:inset 0 0 0 1px #000,inset 0 3px 8px rgba(0,0,0,.7),0 1px 0 rgba(255,255,255,.06)
}
.kbwrap::before{
  content:"";display:block;height:7px;margin:0 -5px;border-radius:5px 5px 0 0;
  background:linear-gradient(180deg,#6a1d18,#45120f);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.08),inset 0 -1px 0 rgba(0,0,0,.6)
}
.kbscroll{overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;scrollbar-width:thin}
.kb{position:relative;display:flex;height:128px;min-width:660px;touch-action:none;-webkit-user-select:none;user-select:none}
.wkey{
  position:relative;flex:1 0 0;cursor:pointer;border-radius:0 0 4px 4px;
  background:linear-gradient(180deg,#dedad2 0,#f7f5f0 10px,#fbfaf6 62%,#ece8df 100%);
  box-shadow:inset -1px 0 0 rgba(0,0,0,.3),inset 1px 0 0 rgba(255,255,255,.9),0 2px 2px rgba(0,0,0,.45)
}
.wkey::after{
  content:"";position:absolute;left:1px;right:1px;bottom:0;height:8px;border-radius:0 0 3px 3px;pointer-events:none;
  background:linear-gradient(180deg,#d9d4c9,#c2bcaf);box-shadow:inset 0 1px 0 rgba(0,0,0,.16)
}
.wkey[data-down="1"]{background:linear-gradient(180deg,#cfcbc2 0,#e6e3dc 12px,#ebe8e1 100%)}
.wkey[data-down="1"]::after{height:4px}
.wkey[data-latched="1"]{background:linear-gradient(180deg,#d9c39b 0,#f6e2bd 12px,#f2dcb3 100%)}
.wkey[data-play="1"]{background:linear-gradient(180deg,#e2963c 0,#ffbd5e 12px,#f7a945 100%)}
.wkey .kn{position:absolute;left:0;right:0;bottom:13px;text-align:center;font-family:var(--f-disp);font-size:9px;line-height:1;color:#a39e93;pointer-events:none}
.bkey{
  position:absolute;top:0;height:62%;z-index:2;cursor:pointer;border-radius:0 0 3px 3px;
  background:linear-gradient(180deg,#141516 0%,#232527 70%,#3b3e41 84%,#1a1b1c 86%,#0f1011 100%);
  box-shadow:inset 1px 0 0 rgba(255,255,255,.07),inset -1px 0 0 rgba(0,0,0,.6),0 3px 4px rgba(0,0,0,.55)
}
.bkey[data-down="1"]{background:linear-gradient(180deg,#0f1011 0%,#1b1c1e 78%,#2c2e30 90%,#141516 100%);box-shadow:0 1px 2px rgba(0,0,0,.6)}
.bkey[data-latched="1"]{background:linear-gradient(180deg,#3d2a12 0%,#5a3d17 76%,#7a5420 88%,#2e200d 100%)}
.bkey[data-play="1"]{background:linear-gradient(180deg,#a8601a 0%,#d98226 76%,#f0a548 88%,#8a4d14 100%)}

/* ---------- layouts ---------- */
/* smaller laptops: same single row, tighter spacing */
@media (min-width:1240px) and (max-width:1339px){
  :root{--g:5px;--cheek:14px}
  body{padding:20px 8px 48px}
  .chassis{padding:12px 8px}
  .row{padding:12px 6px 8px}
}
/* below that the panel folds into two tiers */
@media (min-width:901px) and (max-width:1239px){
  .panel{display:grid;grid-template-columns:repeat(24,minmax(0,1fr))}
  .sec:nth-child(1){grid-column:span 4}
  .sec:nth-child(2){grid-column:span 7}
  .sec:nth-child(3){grid-column:span 7}
  .sec:nth-child(4){grid-column:span 6}
  .sec:nth-child(5){grid-column:span 9}
  .sec:nth-child(6){grid-column:span 6}
  .sec:nth-child(7){grid-column:span 9}
}
/* tablets: three tiers */
@media (min-width:701px) and (max-width:900px){
  :root{--fh:120px;--cheek:14px}
  body{padding:16px 8px 40px}
  .panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr))}
  .sec:nth-child(1){grid-column:span 3}
  .sec:nth-child(2){grid-column:span 5}
  .sec:nth-child(3){grid-column:span 4}
  .sec:nth-child(4){grid-column:span 6}
  .sec:nth-child(5){grid-column:span 6}
  .sec:nth-child(6){grid-column:span 4}
  .sec:nth-child(7){grid-column:span 8}
}
@media (max-width:900px){
  .lower{flex-direction:column}
  .grip{flex:0 0 auto}
  .pad{align-self:center;width:100%;max-width:380px;min-height:80px}
  .readout{flex-basis:100%}
}
/* phones: swipe through the panel one section at a time */
@media (max-width:700px){
  :root{--fw:40px;--fh:156px;--caph:22px;--g:14px;--sw-lh:17px;--sw-pitch:25px;--cheek:9px}
  body{padding:12px 6px 36px}
  .chassis{padding:10px 8px}
  .header{gap:12px 18px;padding:2px 2px 12px}
  .badge-wrap{flex:1 1 100%}
  .screen{flex:1 1 100%;width:auto;height:58px}
  .program{flex:1 1 100%}
  .program .sel{flex:1 1 auto;width:auto}
  .panel{flex-wrap:nowrap;overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x proximity;-webkit-overflow-scrolling:touch;scrollbar-width:thin}
  .sec{flex:0 0 auto;min-width:calc(100vw - 58px);scroll-snap-align:center}
  .row{justify-content:center;padding:14px 10px 10px}
  .lbl{font-size:10.5px}
  .sw .opt{font-size:11px}
  .sw .rail{flex-basis:14px;width:14px}
  .sw .lever{width:12px}
  .fader .chip{font-size:12px}
  .kb{min-width:900px;height:136px}
  .deck{padding:10px 8px}
  .transport,.bank{gap:12px 14px}
  .bank > .grp:first-child{flex:1 1 100%}
  .slots{display:grid;grid-template-columns:repeat(8,minmax(0,1fr));gap:4px}
  .slot{width:auto}
}
</style>
</head>
<body>
<main class="stage">
  <div class="instrument" id="synth">
    <div class="cheek cheek-l" aria-hidden="true"></div>
    <div class="chassis">
      <span class="screw tl" aria-hidden="true"></span>
      <span class="screw tr" aria-hidden="true"></span>
      <span class="screw bl" aria-hidden="true"></span>
      <span class="screw br" aria-hidden="true"></span>

      <header class="header">
        <div class="badge-wrap">
          <div class="badge"><span class="wm">SH-101</span></div>
          <div class="subtitle">Monophonic synthesizer</div>
        </div>
        <div class="screen"><canvas id="scope" width="388" height="92" aria-hidden="true"></canvas></div>
        <div class="hgrp program">
          <label class="hlbl" for="presets">Patch</label>
          <div class="hrow">
            <select class="sel" id="presets"></select>
            <button class="btn btn-sm" id="patchSave">Save</button>
            <button class="btn btn-sm" id="patchUndo" disabled>Undo</button>
            <button class="btn btn-sm" id="patchDel" disabled>Delete</button>
          </div>
        </div>
        <div class="hgrp">
          <span class="hlbl" id="finishLbl">Finish</span>
          <div class="swatches" role="group" aria-labelledby="finishLbl">
            <button class="swatch silver" data-model="silver" aria-label="Brushed silver" aria-pressed="true"></button>
            <button class="swatch champagne" data-model="champagne" aria-label="Champagne" aria-pressed="false"></button>
            <button class="swatch black" data-model="black" aria-label="Black" aria-pressed="false"></button>
          </div>
        </div>
        <button class="pwr" id="power" data-on="0" aria-pressed="false">
          <span class="hlbl">Power</span>
          <span class="pw-row"><span class="rocker" aria-hidden="true"><i></i></span><span class="pilot" aria-hidden="true"></span></span>
        </button>
      </header>

      <div class="panel">

        <section class="sec">
          <h2>LFO</h2>
          <div class="row">
            <div class="swc"><div class="swbox"><div class="sw" data-p="lfoWave" data-opts="Tri|Sqr|Rnd|Noise" tabindex="0" role="radiogroup" aria-label="LFO waveform"></div></div><div class="lbl">Wave</div></div>
            <div class="ctrl"><div class="fader" data-p="lfoRate" tabindex="0" role="slider" aria-label="LFO rate"></div><div class="lbl">Rate</div></div>
            <div class="ledcol"><div class="swbox"><span class="led" id="lfoLed" aria-hidden="true"></span></div><div class="lbl"></div></div>
          </div>
        </section>

        <section class="sec">
          <h2>VCO</h2>
          <div class="row">
            <div class="swc"><div class="swbox"><div class="sw" data-p="vcoRange" data-opts="16'|8'|4'|2'" tabindex="0" role="radiogroup" aria-label="VCO range"></div></div><div class="lbl">Range</div></div>
            <div class="ctrl"><div class="fader" data-p="tune" tabindex="0" role="slider" aria-label="Tune"></div><div class="lbl">Tune</div></div>
            <div class="ctrl"><div class="fader" data-p="vcoMod" tabindex="0" role="slider" aria-label="LFO pitch modulation"></div><div class="lbl">Mod</div></div>
            <div class="ctrl"><div class="fader" data-p="pw" tabindex="0" role="slider" aria-label="Pulse width"></div><div class="lbl">PW</div></div>
            <div class="swc"><div class="swbox"><div class="sw" data-p="pwmMode" data-opts="LFO|Man|Env" tabindex="0" role="radiogroup" aria-label="PWM source"></div></div><div class="lbl">PWM</div></div>
          </div>
        </section>

        <section class="sec">
          <h2>Mixer</h2>
          <div class="row">
            <div class="ctrl"><div class="fader" data-p="mixPulse" tabindex="0" role="slider" aria-label="Pulse level"></div><div class="lbl">Pulse</div></div>
            <div class="ctrl"><div class="fader" data-p="mixSaw" tabindex="0" role="slider" aria-label="Sawtooth level"></div><div class="lbl">Saw</div></div>
            <div class="ctrl"><div class="fader" data-p="mixSub" tabindex="0" role="slider" aria-label="Sub oscillator level"></div><div class="lbl">Sub</div></div>
            <div class="swc"><div class="swbox"><div class="sw" data-p="subType" data-opts="1 oct|2 oct|2 pls" tabindex="0" role="radiogroup" aria-label="Sub oscillator waveform"></div></div><div class="lbl">Sub wave</div></div>
            <div class="ctrl"><div class="fader" data-p="mixNoise" tabindex="0" role="slider" aria-label="Noise level"></div><div class="lbl">Noise</div></div>
          </div>
        </section>

        <section class="sec">
          <h2>VCF</h2>
          <div class="row">
            <div class="ctrl"><div class="fader" data-p="vcfFreq" tabindex="0" role="slider" aria-label="Cutoff frequency"></div><div class="lbl">Freq</div></div>
            <div class="ctrl"><div class="fader" data-p="vcfRes" tabindex="0" role="slider" aria-label="Resonance"></div><div class="lbl">Res</div></div>
            <div class="ctrl"><div class="fader" data-p="vcfEnv" tabindex="0" role="slider" aria-label="Filter envelope amount"></div><div class="lbl">Env</div></div>
            <div class="ctrl"><div class="fader" data-p="vcfMod" tabindex="0" role="slider" aria-label="Filter LFO modulation"></div><div class="lbl">Mod</div></div>
            <div class="ctrl"><div class="fader" data-p="vcfKbd" tabindex="0" role="slider" aria-label="Keyboard follow"></div><div class="lbl">Kybd</div></div>
          </div>
        </section>

        <section class="sec">
          <h2>Envelope</h2>
          <div class="row">
            <div class="ctrl"><div class="fader" data-p="envA" tabindex="0" role="slider" aria-label="Attack"></div><div class="lbl">A</div></div>
            <div class="ctrl"><div class="fader" data-p="envD" tabindex="0" role="slider" aria-label="Decay"></div><div class="lbl">D</div></div>
            <div class="ctrl"><div class="fader" data-p="envS" tabindex="0" role="slider" aria-label="Sustain"></div><div class="lbl">S</div></div>
            <div class="ctrl"><div class="fader" data-p="envR" tabindex="0" role="slider" aria-label="Release"></div><div class="lbl">R</div></div>
            <div class="swc"><div class="swbox"><div class="sw" data-p="envTrig" data-opts="G+T|Gate|LFO" tabindex="0" role="radiogroup" aria-label="Envelope trigger"></div></div><div class="lbl">Trig</div></div>
          </div>
        </section>

        <section class="sec">
          <h2>VCA</h2>
          <div class="row">
            <div class="swc"><div class="swbox"><div class="sw" data-p="vcaMode" data-opts="Env|Gate" tabindex="0" role="radiogroup" aria-label="VCA mode"></div></div><div class="lbl">Mode</div></div>
            <div class="ctrl"><div class="fader" data-p="volume" tabindex="0" role="slider" aria-label="Volume"></div><div class="lbl">Volume</div></div>
          </div>
        </section>

        <section class="sec">
          <h2>Performance</h2>
          <div class="row">
            <div class="ctrl"><div class="fader" data-p="porta" tabindex="0" role="slider" aria-label="Portamento time"></div><div class="lbl">Porta</div></div>
            <div class="swc"><div class="swbox"><div class="sw" data-p="portaMode" data-opts="Auto|Off|On" tabindex="0" role="radiogroup" aria-label="Portamento mode"></div></div><div class="lbl">Mode</div></div>
            <div class="ctrl"><div class="fader" data-p="bendVco" tabindex="0" role="slider" aria-label="Bend pitch sensitivity"></div><div class="lbl">Bend VCO</div></div>
            <div class="ctrl"><div class="fader" data-p="bendVcf" tabindex="0" role="slider" aria-label="Bend filter sensitivity"></div><div class="lbl">Bend VCF</div></div>
            <div class="ctrl"><div class="fader" data-p="gripMod" tabindex="0" role="slider" aria-label="Joystick LFO modulation depth"></div><div class="lbl">LFO mod</div></div>
          </div>
        </section>

      </div>

      <div class="deck">
        <div class="transport">
          <div class="grp">
            <div class="gname">Sequencer</div>
            <div class="btns">
              <button class="btn" id="seqRec"><span class="led"></span>Rec</button>
              <button class="btn" id="seqPlay"><span class="led"></span>Play</button>
              <button class="btn" id="seqRest">Rest</button>
              <button class="btn" id="seqUndo">Undo step</button>
              <button class="btn" id="seqClear">Clear</button>
            </div>
          </div>
          <div class="grp">
            <div class="gname">Arpeggio</div>
            <div class="btns">
              <button class="btn" id="arpUp"><span class="led"></span>Up</button>
              <button class="btn" id="arpUD"><span class="led"></span>U&amp;D</button>
              <button class="btn" id="arpDown"><span class="led"></span>Down</button>
              <button class="btn" id="hold"><span class="led"></span>Hold</button>
            </div>
          </div>
          <div class="grp">
            <div class="gname">Transpose</div>
            <div class="btns">
              <button class="btn btn-sq" id="octDown" aria-label="Octave down">&minus;</button>
              <span class="octval" id="octVal">0</span>
              <button class="btn btn-sq" id="octUp" aria-label="Octave up">+</button>
            </div>
          </div>
          <div class="readout" id="readout">Press <b>Power</b> to start</div>
          <div class="strip" id="seqstrip"></div>
        </div>

        <div class="bank">
          <div class="grp">
            <div class="gname">Patterns</div>
            <div class="slots" id="slots"></div>
          </div>
          <div class="grp">
            <div class="btns">
              <button class="btn" id="patStore">Store</button>
              <button class="btn" id="patLoad">Load</button>
              <button class="btn" id="patAdd">Add</button>
              <button class="btn" id="patClearSlot">Erase</button>
            </div>
          </div>
          <div class="grp">
            <div class="gname">Chain</div>
            <div class="btns">
              <button class="btn" id="chainPlay"><span class="led"></span>Play</button>
              <button class="btn" id="chainClear">Clear</button>
              <button class="btn" id="exportWav">Export audio</button>
            </div>
          </div>
          <div class="strip" id="chainstrip"></div>
        </div>
      </div>

      <div class="lower">
        <div class="grip">
          <div class="gname">Bend and mod</div>
          <div class="pad" id="pad" tabindex="0" role="application" aria-label="Joystick: drag left and right to bend, push up for LFO modulation">
            <div class="cross" aria-hidden="true"><i class="h"></i><i class="v"></i></div>
            <span class="hint" aria-hidden="true">mod</span>
            <div class="lever" id="lever"></div>
          </div>
        </div>
        <div class="kbwrap">
          <div class="kbscroll"><div class="kb" id="kb"></div></div>
        </div>
      </div>
    </div>
    <div class="cheek cheek-r" aria-hidden="true"></div>
  </div>

</main>

<script>
(function(){
"use strict";

/* ============================ parameters ============================ */
var SW = {lfoWave:4, vcoRange:4, pwmMode:3, subType:3, envTrig:3, vcaMode:2, portaMode:3};

var DEF = {
  lfoWave:0, lfoRate:0.36,
  vcoRange:1, tune:0.5, vcoMod:0, pw:0, pwmMode:1,
  mixPulse:0, mixSaw:0.85, mixSub:0.45, subType:0, mixNoise:0,
  vcfFreq:0.46, vcfRes:0.28, vcfEnv:0.5, vcfMod:0, vcfKbd:0.5,
  envA:0.02, envD:0.42, envS:0.35, envR:0.22, envTrig:0,
  vcaMode:0, volume:0.78,
  porta:0, portaMode:1, bendVco:0.25, bendVcf:0, gripMod:0.3
};
var P = {};
for (var k in DEF) P[k] = DEF[k];

function expMap(v,a,b){ return a*Math.pow(b/a,v); }
function clamp(v,a,b){ return v<a?a:(v>b?b:v); }

var S = {
  lfoRate:  function(){ return expMap(P.lfoRate,0.1,30); },
  rangeMul: function(){ return [0.5,1,2,4][P.vcoRange]; },
  tuneC:    function(){ return (P.tune-0.5)*100; },
  vcoModC:  function(){ return P.vcoMod*P.vcoMod*1200; },
  pwBase:   function(){ return P.pwmMode===0 ? 0.5-P.pw*0.23 : 0.5-P.pw*0.46; },
  pwDepth:  function(){ return P.pwmMode===0 ? P.pw*0.23 : (P.pwmMode===2 ? P.pw*0.46 : 0); },
  cutoff:   function(){ return expMap(P.vcfFreq,18,18000); },
  resQ:     function(){ return P.vcfRes; },
  vcfEnvC:  function(){ return P.vcfEnv*7600; },
  vcfModC:  function(){ return P.vcfMod*P.vcfMod*4800; },
  atk:      function(){ return expMap(P.envA,0.0015,4); },
  dec:      function(){ return expMap(P.envD,0.003,10); },
  rel:      function(){ return expMap(P.envR,0.003,10); },
  porta:    function(){ return P.porta*P.porta*5; },
  bendVcoC: function(){ return P.bendVco*1200; },
  bendVcfC: function(){ return P.bendVcf*4800; },
  vol:      function(){ return P.volume*P.volume; }
};

function fmt(p){
  var v=P[p];
  switch(p){
    case 'lfoRate': var r=S.lfoRate(); return (r<10? r.toFixed(2): r.toFixed(1))+'Hz';
    case 'vcfFreq': var f=S.cutoff(); return f>=1000 ? (f/1000).toFixed(1)+'k' : Math.round(f)+'';
    case 'tune':    var c=S.tuneC(); return (c>0?'+':'')+c.toFixed(0)+'\u00A2';
    case 'envA':    return tstr(S.atk());
    case 'envD':    return tstr(S.dec());
    case 'envR':    return tstr(S.rel());
    case 'porta':   return S.porta()<0.005?'off':tstr(S.porta());
    case 'pw':      return P.pwmMode===1 ? Math.round((0.5-P.pw*0.46)*100)+'%' : Math.round(P.pw*100)+'';
    default: return Math.round(v*100)+'';
  }
}
function tstr(t){ return t<1 ? Math.round(t*1000)+'ms' : t.toFixed(t<10?2:1)+'s'; }

/* ============================ audio engine ============================ */
var ctx=null, A={}, powered=false, currentFreq=261.63, lastNote=60;

function gain(v){ var g=ctx.createGain(); g.gain.value=v; return g; }
function makeNoise(){
  var len=Math.floor(ctx.sampleRate*2), b=ctx.createBuffer(1,len,ctx.sampleRate), d=b.getChannelData(0);
  for(var i=0;i<len;i++) d[i]=Math.random()*2-1;
  var s=ctx.createBufferSource(); s.buffer=b; s.loop=true; return s;
}
function pulseWave(duty){
  var n=48, re=new Float32Array(n+1), im=new Float32Array(n+1);
  for(var i=1;i<=n;i++) re[i]=(2/(i*Math.PI))*Math.sin(i*Math.PI*duty);
  return ctx.createPeriodicWave(re,im,{disableNormalization:false});
}
function driveCurve(a){
  var n=2048, c=new Float32Array(n), d=Math.tanh(a);
  for(var i=0;i<n;i++){ var x=i*2/n-1; c[i]=Math.tanh(x*a)/d; }
  return c;
}
function hold(par,t){
  if(par.cancelAndHoldAtTime) par.cancelAndHoldAtTime(t);
  else { par.cancelScheduledValues(t); par.setValueAtTime(par.value,t); }
}

function boot(){
  if(ctx) return;
  ctx = new (window.AudioContext||window.webkitAudioContext)();
  buildGraph();
  setPitch(noteFreq(60),ctx.currentTime,0);
  applyAll();
  nextTick = ctx.currentTime + 0.05;
  if(!clockTimer) clockTimer = setInterval(scheduler,25);
}
var instantParams=false, rendering=false;
function buildGraph(){
  A.master=gain(0);
  A.comp=ctx.createDynamicsCompressor();
  A.comp.threshold.value=-9; A.comp.knee.value=16; A.comp.ratio.value=6;
  A.comp.attack.value=0.004; A.comp.release.value=0.22;
  A.master.connect(A.comp); A.comp.connect(ctx.destination);
  A.an=ctx.createAnalyser(); A.an.fftSize=2048; A.an.smoothingTimeConstant=0.62;
  A.comp.connect(A.an);

  A.vca=gain(0); A.vca.connect(A.master);

  A.f1=ctx.createBiquadFilter(); A.f1.type='lowpass';
  A.f2=ctx.createBiquadFilter(); A.f2.type='lowpass';
  A.drive=ctx.createWaveShaper(); A.drive.curve=driveCurve(1.7); A.drive.oversample='2x';
  A.dc=ctx.createBiquadFilter(); A.dc.type='highpass'; A.dc.frequency.value=14; A.dc.Q.value=0.5;
  A.mix=gain(0.5);
  A.mix.connect(A.dc); A.dc.connect(A.drive); A.drive.connect(A.f1);
  A.f1.connect(A.f2); A.f2.connect(A.vca);

  /* oscillator section */
  A.saw=ctx.createOscillator();  A.saw.type='sawtooth';
  A.psaw=ctx.createOscillator(); A.psaw.type='sawtooth';
  A.sub=ctx.createOscillator();  A.sub.type='square';
  A.noise=makeNoise();
  A.subPulse=pulseWave(0.25);

  A.sawG=gain(0);   A.saw.connect(A.sawG);   A.sawG.connect(A.mix);
  A.pwDelay=ctx.createDelay(0.15);
  A.pinv=gain(-1);
  A.pulseSum=gain(0.5);
  A.psaw.connect(A.pwDelay); A.pwDelay.connect(A.pinv);
  A.psaw.connect(A.pulseSum); A.pinv.connect(A.pulseSum);
  A.pulseG=gain(0); A.pulseSum.connect(A.pulseG); A.pulseG.connect(A.mix);
  A.subG=gain(0);   A.sub.connect(A.subG);     A.subG.connect(A.mix);
  A.noiseG=gain(0); A.noise.connect(A.noiseG); A.noiseG.connect(A.mix);

  /* filter self-oscillation */
  A.sine=ctx.createOscillator(); A.sine.type='sine';
  A.sineG=gain(0); A.sine.connect(A.sineG); A.sineG.connect(A.vca);

  /* envelope + gate */
  A.env=ctx.createConstantSource();  A.env.offset.value=0;
  A.gate=ctx.createConstantSource(); A.gate.offset.value=0;
  A.envVca=gain(1);  A.env.connect(A.envVca);   A.envVca.connect(A.vca.gain);
  A.gateVca=gain(0); A.gate.connect(A.gateVca); A.gateVca.connect(A.vca.gain);

  /* LFO */
  A.lfoOsc=ctx.createOscillator(); A.lfoOsc.type='triangle'; A.lfoOsc.frequency.value=S.lfoRate();
  A.lfoRand=ctx.createConstantSource(); A.lfoRand.offset.value=0;
  A.lfoNz=makeNoise();
  A.lfoNzLP=ctx.createBiquadFilter(); A.lfoNzLP.type='lowpass'; A.lfoNzLP.frequency.value=28; A.lfoNzLP.Q.value=0.6;
  A.lfoNz.connect(A.lfoNzLP);
  A.lfoBus=gain(1);
  A.gOsc=gain(1); A.gRand=gain(0); A.gNz=gain(0);
  A.lfoOsc.connect(A.gOsc);   A.gOsc.connect(A.lfoBus);
  A.lfoRand.connect(A.gRand); A.gRand.connect(A.lfoBus);
  A.lfoNzLP.connect(A.gNz);   A.gNz.connect(A.lfoBus);

  /* bender */
  A.bend=ctx.createConstantSource(); A.bend.offset.value=0;
  A.bendP=gain(0); A.bend.connect(A.bendP);
  A.bendF=gain(0); A.bend.connect(A.bendF);

  /* modulation routing */
  A.lfoPitch=gain(0); A.lfoBus.connect(A.lfoPitch);
  A.lfoVcf=gain(0);   A.lfoBus.connect(A.lfoVcf);
  A.lfoPwm=gain(0);   A.lfoBus.connect(A.lfoPwm);
  A.envVcf=gain(0);   A.env.connect(A.envVcf);
  A.envPwm=gain(0);   A.env.connect(A.envPwm);

  [A.lfoPitch,A.bendP].forEach(function(n){
    n.connect(A.saw.detune); n.connect(A.psaw.detune); n.connect(A.sub.detune);
  });
  [A.lfoVcf,A.envVcf,A.bendF].forEach(function(n){
    n.connect(A.f1.detune); n.connect(A.f2.detune); n.connect(A.sine.detune);
  });
  A.lfoPwm.connect(A.pwDelay.delayTime);
  A.envPwm.connect(A.pwDelay.delayTime);

  [A.saw,A.psaw,A.sub,A.noise,A.sine,A.lfoOsc,A.lfoRand,A.lfoNz,A.env,A.gate,A.bend].forEach(function(n){ n.start(0); });

}

var kbOct=0;
function noteFreq(n){ return 440*Math.pow(2,(n+kbOct*12-69)/12)*S.rangeMul(); }

function setPitch(f,t,glide){
  currentFreq=f;
  var tc = glide>0 ? glide/3 : 0;
  var sf = f*[0.5,0.25,0.25][P.subType];
  function go(par,v){
    if(tc>0) par.setTargetAtTime(v,t,tc); else par.setValueAtTime(v,t);
  }
  go(A.saw.frequency,f); go(A.psaw.frequency,f); go(A.sub.frequency,sf);
  var base=S.pwBase()/f;
  if(tc>0) A.pwDelay.delayTime.setTargetAtTime(base,t,tc);
  else A.pwDelay.delayTime.setValueAtTime(base,t);
  A.lfoPwm.gain.value = (P.pwmMode===0 ? S.pwDepth()/f : 0);
  A.envPwm.gain.value = (P.pwmMode===2 ? S.pwDepth()/f : 0);
}

function filterBase(){
  var f=S.cutoff()*Math.pow(2,P.vcfKbd*(lastNote-60)/12);
  return clamp(f,10,ctx.sampleRate*0.45);
}

function applyAll(){
  if(!ctx) return;
  var t=ctx.currentTime, tc=0.012;
  function st(par,v,c){ if(instantParams) par.setValueAtTime(v,t); else par.setTargetAtTime(v,t,c||tc); }

  /* LFO */
  A.lfoOsc.type = P.lfoWave===1 ? 'square' : 'triangle';
  st(A.lfoOsc.frequency,S.lfoRate(),0.02);
  A.gOsc.gain.value  = (P.lfoWave===0||P.lfoWave===1) ? 1 : 0;
  A.gRand.gain.value = P.lfoWave===2 ? 1 : 0;
  A.gNz.gain.value   = P.lfoWave===3 ? 30 : 0;

  /* VCO */
  var tn=S.tuneC();
  A.saw.detune.value=tn; A.psaw.detune.value=tn; A.sub.detune.value=tn;
  st(A.lfoPitch.gain, S.vcoModC() + padY*P.gripMod*1200, 0.03);
  A.sub.frequency.setTargetAtTime(currentFreq*[0.5,0.25,0.25][P.subType],t,0.01);
  if(A._sub!==P.subType){
    A._sub=P.subType;
    if(P.subType===2) A.sub.setPeriodicWave(A.subPulse); else A.sub.type='square';
  }
  var base=S.pwBase()/currentFreq;
  st(A.pwDelay.delayTime,base,0.01);
  A.lfoPwm.gain.value = (P.pwmMode===0 ? S.pwDepth()/currentFreq : 0);
  A.envPwm.gain.value = (P.pwmMode===2 ? S.pwDepth()/currentFreq : 0);

  /* mixer */
  st(A.pulseG.gain,P.mixPulse*0.9);
  st(A.sawG.gain,  P.mixSaw*0.9);
  st(A.subG.gain,  P.mixSub*0.9);
  st(A.noiseG.gain,P.mixNoise*0.55);

  /* filter */
  var r=P.vcfRes;
  st(A.f1.frequency,filterBase(),0.01);
  st(A.f2.frequency,filterBase(),0.01);
  st(A.sine.frequency,filterBase(),0.01);
  A.f1.Q.value=-3+r*9;
  A.f2.Q.value=-3+r*26;
  var so=Math.max(0,(r-0.78)/0.22);
  st(A.sineG.gain,so*so*0.45);
  st(A.mix.gain,0.5*(1-0.28*r));
  st(A.envVcf.gain,S.vcfEnvC(),0.02);
  st(A.lfoVcf.gain,S.vcfModC(),0.02);
  st(A.bendP.gain,S.bendVcoC(),0.02);
  st(A.bendF.gain,S.bendVcfC(),0.02);

  /* VCA */
  A.envVca.gain.value  = P.vcaMode===0?1:0;
  A.gateVca.gain.value = P.vcaMode===1?1:0;
  st(A.master.gain, powered ? S.vol() : 0, 0.03);
}

/* ---- envelope ---- */
function envAttack(t){
  var p=A.env.offset, a=S.atk(), d=S.dec(), s=P.envS;
  hold(p,t);
  p.linearRampToValueAtTime(1,t+a);
  p.setTargetAtTime(s,t+a,Math.max(d,0.002)/3.2);
}
function envRelease(t){
  var p=A.env.offset;
  hold(p,t);
  p.setTargetAtTime(0,t,Math.max(S.rel(),0.002)/3.2);
}
function gateOn(t){ var p=A.gate.offset; hold(p,t); p.linearRampToValueAtTime(1,t+0.003); }
function gateOff(t){ var p=A.gate.offset; hold(p,t); p.linearRampToValueAtTime(0,t+0.004); }

/* ============================ voice / clock ============================ */
var held=[], latched=[], holdOn=false, sounding=false;
var arpMode=0, arpPos=0;
var seqMode='idle', seq=[], seqPos=0, seqTrans=0;
var padX=0, padY=0;
var nextTick=0, clockTimer=null;

function activeSet(){ return holdOn ? latched : held; }
function arpRunning(){ return arpMode>0; }
function priorityNote(list){
  if(!list.length) return null;
  if(P.envTrig===1) return Math.min.apply(null,list);
  return list[list.length-1];
}
function updFilterFreq(t){
  var f=filterBase();
  A.f1.frequency.setValueAtTime(f,t);
  A.f2.frequency.setValueAtTime(f,t);
  A.sine.frequency.setValueAtTime(f,t);
}
function updateVoice(){
  var list=activeSet(), t=ctx.currentTime;
  if(!list.length){
    if(sounding){ envRelease(t); gateOff(t); sounding=false; }
    return;
  }
  var n=priorityNote(list), was=sounding;
  var glide=(P.portaMode===2||(P.portaMode===0&&was))?S.porta():0;
  lastNote=n;
  setPitch(noteFreq(n),t,glide);
  updFilterFreq(t);
  gateOn(t);
  if(P.envTrig===0 || !was) { if(P.envTrig!==2 || !was) envAttack(t); }
  sounding=true;
}
function repaintKeys(){
  var all=document.querySelectorAll('[data-midi]');
  for(var i=0;i<all.length;i++){
    var m=+all[i].dataset.midi;
    all[i].dataset.down=held.indexOf(m)>=0?'1':'0';
    all[i].dataset.latched=(holdOn&&latched.indexOf(m)>=0)?'1':'0';
  }
}
function flash(n,delay,dur){
  if(rendering) return;
  var e=document.querySelector('[data-midi="'+n+'"]'); if(!e) return;
  setTimeout(function(){
    e.dataset.play='1';
    setTimeout(function(){ e.dataset.play='0'; }, dur||120);
  }, Math.max(delay||0,0));
}
function playStep(n,t,step){
  lastNote=n;
  flash(n,(t-ctx.currentTime)*1000,Math.min(Math.max(step*420,70),200));
  setPitch(noteFreq(n),t,0);
  updFilterFreq(t);
  envAttack(t); gateOn(t);
  var off=t+Math.max(step*0.5,0.02);
  envRelease(off); gateOff(off);
}

function noteOn(n){
  if(!ctx) return;
  if(seqMode==='rec'){
    if(seq.length<100){ seq.push({n:n}); blip(n); renderSeq(); save(); }
    flash(n,0,150); updButtons(); readout(); return;
  }
  if(seqMode==='play'){
    var root=seqRoot(); if(root!==null) seqTrans=n-root;
    flash(n,0,150); readout(); return;
  }
  if(holdOn && held.length===0) latched=[];
  if(held.indexOf(n)<0) held.push(n);
  if(holdOn && latched.indexOf(n)<0) latched.push(n);
  repaintKeys();
  if(!arpRunning()) updateVoice();
  readout();
}
function noteOff(n){
  if(!ctx) return;
  if(seqMode==='rec'||seqMode==='play') return;
  var i=held.indexOf(n); if(i>=0) held.splice(i,1);
  repaintKeys();
  if(!arpRunning() && !holdOn) updateVoice();
  if(!arpRunning() && holdOn && latched.length===0) updateVoice();
  readout();
}
function blip(n){
  var t=ctx.currentTime;
  lastNote=n; setPitch(noteFreq(n),t,0); updFilterFreq(t);
  envAttack(t); gateOn(t);
  envRelease(t+0.14); gateOff(t+0.14);
}
function seqRoot(){
  for(var i=0;i<seq.length;i++) if(!seq[i].rest) return seq[i].n;
  return null;
}
function panic(){
  held=[]; latched=[];
  var all=document.querySelectorAll('[data-midi]');
  for(var i=0;i<all.length;i++){ all[i].dataset.down='0'; all[i].dataset.latched='0'; all[i].dataset.play='0'; }
  if(ctx){ var t=ctx.currentTime; envRelease(t); gateOff(t); }
  sounding=false;
}

function releaseVoice(){
  if(ctx){ var t=ctx.currentTime; envRelease(t); gateOff(t); }
  sounding=false;
}
function scheduler(){
  if(!ctx||!powered) return;
  var guard=0;
  if(nextTick < ctx.currentTime) nextTick=ctx.currentTime;
  var ahead = seqMode==='chain' ? 0.06 : 0.12;
  while(nextTick < ctx.currentTime+ahead && guard++<48){
    doTick(nextTick);
    nextTick += 1/Math.max(S.lfoRate(),0.08);
  }
}
function doTick(t){
  if(seqMode==='chain' && chain.length){ chainTick(t); return; }
  if(P.lfoWave===2) A.lfoRand.offset.setValueAtTime(Math.random()*2-1,t);
  var step=1/Math.max(S.lfoRate(),0.08);
  if(seqMode==='play' && seq.length){
    if(seqPos>=seq.length) seqPos=0;
    var idx=seqPos, s=seq[seqPos]; seqPos++;
    markStepAt(idx,t);
    if(s.rest){ envRelease(t); gateOff(t); }
    else playStep(s.n+seqTrans,t,step);
    return;
  }
  var list=activeSet();
  if(arpMode>0 && list.length){
    var order=arpOrder(list);
    if(arpPos>=order.length) arpPos=0;
    playStep(order[arpPos],t,step);
    arpPos=(arpPos+1)%order.length;
    return;
  }
  if(P.envTrig===2 && sounding) envAttack(t);
}
function chainTick(t){
  var guard=0;
  while(guard++<chain.length+1){
    var pat=PAT[chain[chainPos]];
    if(pat && pat.seq.length) break;
    chainPos=(chainPos+1)%chain.length; chainStep=0;
  }
  var pat2=PAT[chain[chainPos]];
  if(!pat2 || !pat2.seq.length) return;
  if(chainStep===0){ applyPatchLive(pat2.p); markChain(chainPos,t); }
  if(P.lfoWave===2) A.lfoRand.offset.setValueAtTime(Math.random()*2-1,t);
  var step=1/Math.max(S.lfoRate(),0.08);
  var s=pat2.seq[chainStep];
  if(s.rest){ envRelease(t); gateOff(t); } else playStep(s.n,t,step);
  chainStep++;
  if(chainStep>=pat2.seq.length){ chainStep=0; chainPos=(chainPos+1)%chain.length; }
}
function arpOrder(list){
  var up=list.slice().sort(function(a,b){return a-b;});
  if(arpMode===1) return up;
  if(arpMode===3) return up.slice().reverse();
  if(up.length<3) return up.concat(up.slice().reverse().slice(1,-1));
  return up.concat(up.slice().reverse().slice(1,-1));
}

/* ============================ UI ============================ */
var $=function(s){return document.querySelector(s);};
var $$=function(s){return Array.prototype.slice.call(document.querySelectorAll(s));};

var userOff=false;
function ensure(){
  if(userOff){ if(ctx && ctx.state==='suspended') ctx.resume(); return; }
  if(!powered) setPower(true);
  else if(ctx && ctx.state==='suspended') ctx.resume();
}
function setPower(on){
  powered=on; userOff=!on;
  if(on){ boot(); if(ctx.state==='suspended') ctx.resume(); } else { panic(); }
  var pb=$('#power');
  pb.dataset.on=on?'1':'0';
  pb.setAttribute('aria-pressed',on?'true':'false');
  applyAll(); readout();
}

/* ---- faders ---- */
function faderUI(p){
  var el=document.querySelector('.fader[data-p="'+p+'"]'); if(!el) return;
  var v=P[p], cap=el.querySelector('.cap');
  cap.style.top='calc('+((1-v)*100).toFixed(2)+'% - '+(1-v).toFixed(3)+' * var(--caph))';
  el.dataset.chip = v>0.5 ? 'low' : 'high';
  el.setAttribute('aria-valuenow',Math.round(v*100));
  el.setAttribute('aria-valuetext',fmt(p));
  var chip=el.querySelector('.chip'); if(chip) chip.textContent=fmt(p);
}
function setParam(p,v){ P[p]=v; faderUI(p); applyAll(); save(); }

$$('.fader').forEach(function(el){
  var p=el.dataset.p;
  el.innerHTML='<div class="track"></div><div class="ticks"></div><div class="cap"></div><div class="chip"></div>';
  var tk=el.querySelector('.ticks');
  for(var i=0;i<=10;i++){ var s=document.createElement('i'); s.style.top=(i*10)+'%'; if(i%5===0) s.className='maj'; tk.appendChild(s); }
  el.setAttribute('aria-valuemin','0'); el.setAttribute('aria-valuemax','100');
  var drag=false, sy=0, sv=0;
  el.addEventListener('pointerdown',function(e){
    ensure(); drag=true; sy=e.clientY; sv=P[p]; el.dataset.act='1';
    try{el.setPointerCapture(e.pointerId);}catch(_){}
    e.preventDefault();
  });
  el.addEventListener('pointermove',function(e){
    if(!drag) return;
    var cap=el.querySelector('.cap');
    var usable=Math.max(el.clientHeight-(cap&&cap.offsetHeight||19),1);
    setParam(p,clamp(sv+(sy-e.clientY)/usable,0,1));
  });
  function end(e){ if(drag){ drag=false; el.dataset.act='0'; try{el.releasePointerCapture(e.pointerId);}catch(_){} } }
  el.addEventListener('pointerup',end); el.addEventListener('pointercancel',end);
  el.addEventListener('dblclick',function(){ setParam(p,DEF[p]); });
  el.addEventListener('keydown',function(e){
    var d=e.shiftKey?0.005:0.02, v=P[p];
    if(e.key==='ArrowUp'||e.key==='ArrowRight') v+=d;
    else if(e.key==='ArrowDown'||e.key==='ArrowLeft') v-=d;
    else if(e.key==='Home') v=1; else if(e.key==='End') v=0;
    else return;
    e.preventDefault(); ensure(); setParam(p,clamp(v,0,1));
  });
});

/* ---- switches ---- */
function switchUI(p){
  var el=document.querySelector('.sw[data-p="'+p+'"]'); if(!el) return;
  var i=P[p];
  el.querySelector('.lever').style.top='calc('+i+' * var(--sw-pitch))';
  el.querySelectorAll('.opt').forEach(function(b,j){ b.setAttribute('aria-checked',j===i?'true':'false'); });
}
function setSwitch(p,i){
  P[p]=i; switchUI(p);
  if(ctx && (p==='vcoRange'||p==='subType')) setPitch(noteFreq(lastNote),ctx.currentTime,0);
  applyAll(); save(); readout();
  ['pw','lfoRate','vcfFreq'].forEach(faderUI);
}
$$('.sw').forEach(function(el){
  var p=el.dataset.p, opts=el.dataset.opts.split('|');
  var rail=document.createElement('div'); rail.className='rail';
  var lev=document.createElement('div'); lev.className='lever'; rail.appendChild(lev);
  var box=document.createElement('div'); box.className='opts';
  opts.forEach(function(o,i){
    var b=document.createElement('button'); b.type='button'; b.className='opt';
    b.textContent=o; b.setAttribute('role','radio'); b.tabIndex=-1;
    b.addEventListener('click',function(){ ensure(); setSwitch(p,i); });
    box.appendChild(b);
  });
  el.appendChild(rail); el.appendChild(box);
  el.addEventListener('keydown',function(e){
    var i=P[p];
    if(e.key==='ArrowDown'||e.key==='ArrowRight') i=Math.min(i+1,opts.length-1);
    else if(e.key==='ArrowUp'||e.key==='ArrowLeft') i=Math.max(i-1,0);
    else return;
    e.preventDefault(); ensure(); setSwitch(p,i);
  });
});

/* ---- keyboard ---- */
var KB_LO=53, KB_HI=84, WHITE=[0,2,4,5,7,9,11];
var KEYMAP={}, KEYNAME={};
(function(){
  var lo=[['z',0],['s',1],['x',2],['d',3],['c',4],['f',5],['v',6],['b',7],['h',8],['n',9],['j',10],['m',11],[',',12],['l',13],['.',14],[';',15],['/',16]];
  var up=[['q',0],['2',1],['w',2],['3',3],['e',4],['4',5],['r',6],['t',7],['6',8],['y',9],['7',10],['u',11],['i',12],['9',13],['o',14],['0',15],['p',16]];
  lo.forEach(function(a){ KEYMAP[a[0]]=53+a[1]; if(!KEYNAME[53+a[1]]) KEYNAME[53+a[1]]=a[0]; });
  up.forEach(function(a){ KEYMAP[a[0]]=65+a[1]; if(!KEYNAME[65+a[1]]) KEYNAME[65+a[1]]=a[0]; });
})();

(function buildKeys(){
  var kb=$('#kb'), whites=[], n;
  for(n=KB_LO;n<=KB_HI;n++) if(WHITE.indexOf(n%12)>=0) whites.push(n);
  kb.style.setProperty('--kw',(100/whites.length)+'%');
  var wi=-1, blacks=[];
  for(n=KB_LO;n<=KB_HI;n++){
    if(WHITE.indexOf(n%12)>=0){
      wi++;
      var d=document.createElement('div');
      d.className='wkey'; d.dataset.midi=n; d.dataset.down='0';
      if(KEYNAME[n]){ var s=document.createElement('span'); s.className='kn'; s.textContent=KEYNAME[n]; d.appendChild(s); }
      kb.appendChild(d);
    } else {
      var b=document.createElement('div');
      b.className='bkey'; b.dataset.midi=n; b.dataset.down='0';
      b.style.left='calc('+(wi+1)+' * var(--kw) - var(--kw) * 0.31)';
      b.style.width='calc(var(--kw) * 0.62)';
      blacks.push(b);
    }
  }
  blacks.forEach(function(b){ kb.appendChild(b); });
})();

function paintKey(n,down){
  var e=document.querySelector('[data-midi="'+n+'"]');
  if(e) e.dataset.down=down?'1':'0';
}
function midiFrom(el){
  if(!el) return null;
  var k=el.closest ? el.closest('[data-midi]') : null;
  return k ? parseInt(k.dataset.midi,10) : null;
}
(function(){
  var kb=$('#kb'), down=false, cur=null;
  kb.addEventListener('pointerdown',function(e){
    ensure(); var m=midiFrom(e.target); if(m===null) return;
    down=true; cur=m; noteOn(m);
    try{kb.setPointerCapture(e.pointerId);}catch(_){}
    e.preventDefault();
  });
  kb.addEventListener('pointermove',function(e){
    if(!down) return;
    var m=midiFrom(document.elementFromPoint(e.clientX,e.clientY));
    if(m!==null && m!==cur){ if(cur!==null) noteOff(cur); cur=m; noteOn(m); }
  });
  function up(){ if(down){ if(cur!==null) noteOff(cur); cur=null; down=false; } }
  window.addEventListener('pointerup',up);
  window.addEventListener('pointercancel',up);
})();

var pressed={};
window.addEventListener('keydown',function(e){
  var t=e.target;
  if(t && (t.tagName==='SELECT'||t.tagName==='INPUT'||t.tagName==='TEXTAREA')) return;
  if(e.metaKey||e.ctrlKey||e.altKey) return;
  var k=e.key.toLowerCase();
  if(!(k in KEYMAP) || pressed[k]) return;
  pressed[k]=true; ensure(); noteOn(KEYMAP[k]); e.preventDefault();
});
window.addEventListener('keyup',function(e){
  var k=e.key.toLowerCase();
  if(pressed[k]){ delete pressed[k]; noteOff(KEYMAP[k]); }
});
window.addEventListener('blur',function(){ pressed={}; panic(); });

/* ---- joystick (bend and mod) ---- */
(function(){
  var pad=$('#pad'), lever=$('#lever'), dragging=false, INSET=13;
  function place(){
    lever.style.left='calc('+INSET+'px + (100% - '+(INSET*2)+'px) * '+(padX*0.5+0.5).toFixed(4)+')';
    lever.style.top='calc('+INSET+'px + (100% - '+(INSET*2)+'px) * '+(1-padY).toFixed(4)+')';
  }
  function from(e){
    var r=pad.getBoundingClientRect();
    var w=Math.max(r.width-INSET*2,1), h=Math.max(r.height-INSET*2,1);
    padX=clamp(((e.clientX-r.left-INSET)/w)*2-1,-1,1);
    padY=clamp(1-(e.clientY-r.top-INSET)/h,0,1);
    padApply(); place();
  }
  pad.addEventListener('pointerdown',function(e){ ensure(); dragging=true; try{pad.setPointerCapture(e.pointerId);}catch(_){} from(e); e.preventDefault(); });
  pad.addEventListener('pointermove',function(e){ if(dragging) from(e); });
  function end(){ dragging=false; }
  pad.addEventListener('pointerup',end); pad.addEventListener('pointercancel',end);
  pad.addEventListener('keydown',function(e){
    if(e.key==='ArrowLeft') padX=clamp(padX-0.2,-1,1);
    else if(e.key==='ArrowRight') padX=clamp(padX+0.2,-1,1);
    else if(e.key==='ArrowUp') padY=clamp(padY+0.2,0,1);
    else if(e.key==='ArrowDown') padY=clamp(padY-0.2,0,1);
    else return;
    e.preventDefault(); ensure(); padApply(); place();
  });
  window.__padSpring=function(){
    if(dragging) return;
    if(Math.abs(padX)>0.0005||padY>0.0005){
      padX*=0.8; padY*=0.8;
      if(Math.abs(padX)<0.0005) padX=0;
      if(padY<0.0005) padY=0;
      padApply(); place();
    }
  };
  place();
})();
function padApply(){
  if(!ctx) return;
  var t=ctx.currentTime;
  A.bend.offset.setTargetAtTime(padX,t,0.01);
  A.lfoPitch.gain.setTargetAtTime(S.vcoModC()+padY*P.gripMod*1200,t,0.02);
}

/* ---- pattern bank ---- */
var PAT=[null,null,null,null,null,null,null,null];
var patSel=0, chain=[], chainPos=0, chainStep=0, chainSaved=null;
var exporting=false, exportMsg='';
function slotLetter(i){ return String.fromCharCode(65+i); }
function clonePat(a){ return a.map(function(x){ return x.rest?{rest:true}:{n:x.n}; }); }
function renderSlots(){
  var el=$('#slots'); if(!el) return;
  if(!el.children.length){
    for(var i=0;i<8;i++){
      var b=document.createElement('button');
      b.type='button'; b.className='slot'; b.dataset.i=i;
      b.innerHTML='<span class="ltr">'+slotLetter(i)+'</span><span class="ct"></span>';
      b.addEventListener('click',(function(k){ return function(){ patSel=k; renderSlots(); updButtons(); readout(); }; })(i));
      el.appendChild(b);
    }
  }
  for(var j=0;j<8;j++){
    var c=el.children[j], pt=PAT[j];
    c.dataset.full=pt?'1':'0';
    c.dataset.sel=(j===patSel)?'1':'0';
    c.querySelector('.ct').textContent=pt?pt.seq.length:'\u2013';
    c.setAttribute('aria-label','Pattern '+slotLetter(j)+(pt?', '+pt.seq.length+' steps':', empty'));
  }
}
function renderChain(){
  var el=$('#chainstrip'); if(!el) return;
  if(!chain.length){
    el.innerHTML='<span class="seqempty">Chain is empty. Select a pattern and press Add to build an arrangement.</span>';
    return;
  }
  var h='';
  for(var i=0;i<chain.length;i++) h+='<span class="chaincell" data-i="'+i+'">'+slotLetter(chain[i])+'</span>';
  el.innerHTML=h;
}
function markChain(pos,t){
  var d=(t-ctx.currentTime)*1000;
  setTimeout(function(){
    if(seqMode!=='chain') return;
    var el=$('#chainstrip'); if(!el) return;
    for(var j=0;j<el.children.length;j++) el.children[j].dataset.cur=(j===pos?'1':'0');
    var c=el.children[pos];
    if(c && el.scrollWidth>el.clientWidth) el.scrollLeft=Math.max(0,c.offsetLeft-el.clientWidth/2);
    readout();
  }, Math.max(d,0));
}
function clearChainMarks(){
  var el=$('#chainstrip'); if(!el) return;
  for(var j=0;j<el.children.length;j++) el.children[j].dataset.cur='0';
}
function applyPatchLive(pp){
  for(var k in DEF) if(typeof pp[k]==='number') P[k]=pp[k];
  for(var k2 in P){ if(SW[k2]!==undefined) switchUI(k2); else faderUI(k2); }
  applyAll();
}
function stopChain(){
  if(seqMode==='chain'){
    seqMode='idle';
    if(chainSaved){ applySnapshot(chainSaved); chainSaved=null; }
    clearChainMarks();
  }
}

/* ---- sequencer strip ---- */
var NOTE_NAMES=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
function noteName(n){ return NOTE_NAMES[((n%12)+12)%12]+(Math.floor(n/12)-1); }
function renderSeq(){
  var el=$('#seqstrip'); if(!el) return;
  if(!seq.length){
    el.innerHTML='<span class="seqempty">Empty. Press Rec, then play a line one note at a time \u2014 Rest adds a gap.</span>';
    return;
  }
  var h='';
  for(var i=0;i<seq.length;i++) h+='<span class="seqcell" data-i="'+i+'">'+(seq[i].rest?'\u00B7':noteName(seq[i].n))+'</span>';
  el.innerHTML=h;
}
function markStep(i){
  var el=$('#seqstrip'); if(!el||!el.children.length) return;
  for(var j=0;j<el.children.length;j++) el.children[j].dataset.cur = (j===i?'1':'0');
  var c=el.children[i];
  if(c && el.scrollWidth>el.clientWidth) el.scrollLeft=Math.max(0,c.offsetLeft-el.clientWidth/2);
}
function clearMarks(){
  var el=$('#seqstrip'); if(!el) return;
  for(var j=0;j<el.children.length;j++) el.children[j].dataset.cur='0';
}
function markStepAt(i,t){
  var d=(t-ctx.currentTime)*1000;
  setTimeout(function(){ if(seqMode==='play') markStep(i); }, Math.max(d,0));
}

/* ---- transport ---- */
function updButtons(){
  function led(id,on){ var b=$(id); if(b){ var l=b.querySelector('.led'); if(l) l.dataset.on=on?'1':'0'; } }
  led('#seqRec',seqMode==='rec'); led('#seqPlay',seqMode==='play');
  led('#arpUp',arpMode===1); led('#arpUD',arpMode===2); led('#arpDown',arpMode===3);
  led('#hold',holdOn);
  $('#seqRest').disabled = seqMode!=='rec';
  $('#seqUndo').disabled = !(seqMode==='rec' && seq.length>0);
  $('#seqPlay').disabled = !seq.length && seqMode!=='play';
  $('#seqClear').disabled = !seq.length;
  led('#chainPlay',seqMode==='chain');
  var b;
  if((b=$('#patStore'))) b.disabled = !seq.length;
  if((b=$('#patLoad'))) b.disabled = !PAT[patSel];
  if((b=$('#patAdd'))) b.disabled = !PAT[patSel];
  if((b=$('#patClearSlot'))) b.disabled = !PAT[patSel];
  if((b=$('#chainPlay'))) b.disabled = !chain.length;
  if((b=$('#chainClear'))) b.disabled = !chain.length;
  if((b=$('#exportWav'))) b.disabled = exporting || !(chain.length || PAT[patSel]);
}
function octUI(){ var e=$('#octVal'); if(e) e.textContent=(kbOct>0?'+':'')+kbOct; }
function setOct(v){
  kbOct=clamp(v,-2,2); octUI(); save();
  if(ctx && sounding){ var t=ctx.currentTime; setPitch(noteFreq(lastNote),t,0); updFilterFreq(t); }
}
$('#seqRec').addEventListener('click',function(){
  ensure(); stopChain();
  if(seqMode==='rec'){ seqMode='idle'; }
  else { seqMode='rec'; seq=[]; seqPos=0; seqTrans=0; panic(); }
  clearMarks(); renderSeq(); updButtons(); readout(); save();
});
$('#seqPlay').addEventListener('click',function(){
  ensure(); stopChain();
  if(seqMode==='play'){ seqMode='idle'; panic(); clearMarks(); }
  else if(seq.length){ seqMode='play'; seqPos=0; seqTrans=0; arpMode=0; panic(); nextTick=ctx.currentTime+0.02; }
  updButtons(); readout();
});
$('#seqRest').addEventListener('click',function(){
  if(seqMode==='rec' && seq.length<100){ seq.push({rest:true}); renderSeq(); updButtons(); readout(); save(); }
});
$('#seqUndo').addEventListener('click',function(){
  if(seqMode==='rec' && seq.length){ seq.pop(); renderSeq(); updButtons(); readout(); save(); }
});
$('#seqClear').addEventListener('click',function(){
  seq=[]; seqMode='idle'; seqTrans=0; panic(); renderSeq(); updButtons(); readout(); save();
});
$('#seqstrip').addEventListener('click',function(e){
  var c=e.target.closest ? e.target.closest('.seqcell') : null;
  if(!c || seqMode!=='rec') return;
  seq.splice(+c.dataset.i,1); renderSeq(); updButtons(); readout(); save();
});
$('#octDown').addEventListener('click',function(){ ensure(); setOct(kbOct-1); });
$('#octUp').addEventListener('click',function(){ ensure(); setOct(kbOct+1); });
[['#arpUp',1],['#arpUD',2],['#arpDown',3]].forEach(function(a){
  $(a[0]).addEventListener('click',function(){
    ensure(); stopChain();
    arpMode = arpMode===a[1] ? 0 : a[1];
    arpPos=0;
    if(seqMode==='play'){ seqMode='idle'; clearMarks(); }
    releaseVoice();
    if(!arpRunning()) updateVoice();
    repaintKeys(); updButtons(); readout();
  });
});
$('#hold').addEventListener('click',function(){
  ensure();
  holdOn=!holdOn;
  if(holdOn){ latched=held.slice(); }
  else { latched=[]; if(!arpRunning()) updateVoice(); }
  repaintKeys(); updButtons(); readout();
});
$('#patStore').addEventListener('click',function(){
  if(!seq.length) return;
  PAT[patSel]={name:slotLetter(patSel),seq:clonePat(seq),p:snapshot()};
  renderSlots(); updButtons(); readout(); save();
});
$('#patLoad').addEventListener('click',function(){
  var pt=PAT[patSel]; if(!pt) return;
  ensure(); stopChain();
  seq=clonePat(pt.seq); seqPos=0; seqTrans=0; seqMode='idle';
  applySnapshot(pt.p);
});
$('#patAdd').addEventListener('click',function(){
  if(!PAT[patSel] || chain.length>=64) return;
  chain.push(patSel); renderChain(); updButtons(); readout(); save();
});
$('#patClearSlot').addEventListener('click',function(){
  if(!PAT[patSel]) return;
  PAT[patSel]=null;
  chain=chain.filter(function(i){ return PAT[i]; });
  if(seqMode==='chain' && !chain.length) stopChain();
  renderSlots(); renderChain(); updButtons(); readout(); save();
});
$('#chainstrip').addEventListener('click',function(e){
  var c=e.target.closest ? e.target.closest('.chaincell') : null;
  if(!c) return;
  chain.splice(+c.dataset.i,1);
  if(chainPos>=chain.length) chainPos=0;
  if(!chain.length) stopChain();
  renderChain(); updButtons(); readout(); save();
});
$('#chainPlay').addEventListener('click',function(){
  ensure();
  if(seqMode==='chain'){ stopChain(); panic(); }
  else if(chain.length){
    chainSaved=snapshot();
    seqMode='chain'; chainPos=0; chainStep=0; arpMode=0;
    panic(); clearMarks(); nextTick=ctx.currentTime+0.02;
  }
  updButtons(); readout();
});
$('#chainClear').addEventListener('click',function(){
  stopChain(); chain=[]; chainPos=0; chainStep=0;
  renderChain(); updButtons(); readout(); save();
});
$('#exportWav').addEventListener('click',function(){ exportAudio(); });
$('#power').addEventListener('click',function(){ setPower(!powered); });

function readout(){
  var r=$('#readout'), txt;
  if(exportMsg) txt=exportMsg;
  else if(!powered) txt='Press <b>Power</b> to start';
  else if(seqMode==='chain') txt='<b>Chain</b> \u00b7 '+chain.length+' patterns \u00b7 playing '+slotLetter(chain[chainPos]||0)+' ('+(chainPos+1)+'/'+chain.length+')';
  else if(seqMode==='rec') txt='<b>Recording</b> · '+seq.length+' steps · play notes, add rests';
  else if(seqMode==='play') txt='<b>Sequence</b> · '+seq.length+' steps'+(seqTrans?' · transposed '+(seqTrans>0?'+':'')+seqTrans:'')+' · '+S.lfoRate().toFixed(2)+' Hz';
  else if(arpMode) txt='<b>'+['','Up','Up &amp; down','Down'][arpMode]+'</b> arpeggio · '+S.lfoRate().toFixed(2)+' Hz';
  else txt='<b>Ready</b> · clock '+S.lfoRate().toFixed(2)+' Hz'+(seq.length?' · '+seq.length+' steps stored':'');
  r.innerHTML=txt;
}

/* ---- patches ---- */
var PRESETS=[
 {name:'Init',p:{}},
 {name:'Acid bass',p:{mixSaw:1,mixSub:0.3,vcoRange:0,vcfFreq:0.24,vcfRes:0.72,vcfEnv:0.55,vcfKbd:0.3,envA:0,envD:0.3,envS:0,envR:0.18,porta:0.32,portaMode:0}},
 {name:'Sub bass',p:{mixSaw:0,mixPulse:0.5,mixSub:1,subType:0,vcoRange:0,vcfFreq:0.3,vcfRes:0.12,vcfEnv:0.26,envA:0,envD:0.5,envS:0.25,envR:0.22}},
 {name:'Rubber pluck',p:{mixSaw:0.7,mixSub:0.6,subType:1,vcfFreq:0.2,vcfRes:0.56,vcfEnv:0.62,envA:0,envD:0.28,envS:0,envR:0.2}},
 {name:'PWM pad',p:{mixPulse:1,mixSaw:0,mixSub:0.25,pw:0.6,pwmMode:0,lfoRate:0.16,vcfFreq:0.52,vcfRes:0.2,vcfEnv:0.3,envA:0.45,envD:0.6,envS:0.85,envR:0.55}},
 {name:'Lead',p:{mixSaw:0.9,mixPulse:0.5,pw:0.35,pwmMode:1,vcoRange:2,vcoMod:0.25,vcfFreq:0.42,vcfRes:0.45,vcfEnv:0.4,vcfKbd:0.6,envA:0.08,envD:0.5,envS:0.6,envR:0.3,porta:0.3,portaMode:0}},
 {name:'Arp stab',p:{mixPulse:0.8,mixSaw:0.4,mixSub:0.4,pw:0.4,pwmMode:1,vcfFreq:0.3,vcfRes:0.6,vcfEnv:0.55,envA:0,envD:0.22,envS:0,envR:0.1,lfoRate:0.62}},
 {name:'Self-osc kick',p:{mixSaw:0,mixSub:0,mixPulse:0,mixNoise:0,vcfFreq:0.05,vcfRes:1,vcfEnv:0.34,vcfKbd:0,envA:0,envD:0.17,envS:0,envR:0.12}},
 {name:'Noise sweep',p:{mixSaw:0,mixSub:0,mixNoise:1,vcfFreq:0.35,vcfRes:0.62,vcfEnv:0.55,vcfMod:0.5,lfoRate:0.1,envA:0.5,envD:0.6,envS:0.35,envR:0.5}},
 {name:'Sample and hold',p:{mixSaw:0.8,mixPulse:0.4,lfoWave:2,lfoRate:0.55,vcfFreq:0.3,vcfRes:0.5,vcfMod:0.6,vcfEnv:0.2,envA:0,envD:0.5,envS:0.6,envR:0.2}}
];
var userPatches=[], undoState=null;
function snapshot(){ var o={}; for(var k in DEF) o[k]=P[k]; return o; }
function applySnapshot(o){
  for(var k in DEF) if(typeof o[k]==='number') P[k]=o[k];
  if(ctx) setPitch(noteFreq(lastNote),ctx.currentTime,0);
  refreshAll(); save();
}
function allPatches(){ return PRESETS.concat(userPatches); }
function refreshAll(){
  for(var k in P){ if(SW[k]!==undefined) switchUI(k); else faderUI(k); }
  applyAll(); renderSeq(); renderSlots(); renderChain(); octUI(); updButtons(); readout();
}
function loadPreset(i){
  var pr=allPatches()[i]; if(!pr) return;
  for(var k in DEF) P[k]=DEF[k];
  for(var k2 in pr.p) P[k2]=pr.p[k2];
  if(ctx) setPitch(noteFreq(lastNote),ctx.currentTime,0);
  refreshAll(); save();
}
function rebuildPatchList(select){
  var sel=$('#presets'); if(!sel) return;
  sel.innerHTML='';
  allPatches().forEach(function(pr,i){
    var o=document.createElement('option');
    o.value=i; o.textContent=(i>=PRESETS.length?'\u2605 ':'')+pr.name;
    sel.appendChild(o);
  });
  if(select!==undefined && select!==null) sel.value=String(select);
  $('#patchDel').disabled = !(sel.value!=='' && +sel.value>=PRESETS.length);
}
function setUndo(state,label){
  undoState=state;
  var b=$('#patchUndo');
  b.disabled=!state; b.textContent=label||'Undo';
}
(function(){
  var sel=$('#presets');
  rebuildPatchList(0);
  sel.addEventListener('change',function(){
    ensure(); setUndo(snapshot(),'Undo');
    loadPreset(parseInt(sel.value,10));
    $('#patchDel').disabled = +sel.value < PRESETS.length;
  });
  $('#patchSave').addEventListener('click',function(){
    var fallback='My patch '+(userPatches.length+1), name=fallback, cancelled=false;
    try{
      var r=window.prompt('Name this patch',fallback);
      if(r===null) cancelled=true; else if(r.trim()) name=r.trim();
    }catch(e){}
    if(cancelled) return;
    if(userPatches.length>=24) userPatches.shift();
    userPatches.push({name:name,p:snapshot()});
    rebuildPatchList(PRESETS.length+userPatches.length-1);
    save();
  });
  $('#patchUndo').addEventListener('click',function(){
    if(!undoState) return;
    var cur=snapshot();
    applySnapshot(undoState);
    setUndo(cur, $('#patchUndo').textContent==='Undo' ? 'Redo' : 'Undo');
  });
  $('#patchDel').addEventListener('click',function(){
    var sel2=$('#presets'), i=+sel2.value;
    if(i<PRESETS.length) return;
    userPatches.splice(i-PRESETS.length,1);
    rebuildPatchList(0); save();
  });
})();

/* ---- panel finish ---- */
var FINISHES=['silver','champagne','black'];
$$('.swatch').forEach(function(b){
  b.addEventListener('click',function(){
    document.documentElement.dataset.model=b.dataset.model;
    $$('.swatch').forEach(function(o){ o.setAttribute('aria-pressed',o===b?'true':'false'); });
    readColours(); save();
  });
});

/* ---- persistence ---- */
var saveT=null;
function save(){
  clearTimeout(saveT);
  saveT=setTimeout(function(){
    try{ localStorage.setItem('sh101.state',JSON.stringify({
      p:P, model:document.documentElement.dataset.model||'silver',
      seq:seq, oct:kbOct, user:userPatches, pat:PAT, chain:chain
    })); }catch(e){}
  },250);
}
(function restore(){
  try{
    var raw=localStorage.getItem('sh101.state'); if(!raw) return;
    var st=JSON.parse(raw);
    if(st && st.p) for(var k in DEF) if(typeof st.p[k]==='number') P[k]=st.p[k];
    if(st && typeof st.oct==='number') kbOct=clamp(st.oct,-2,2);
    if(st && Object.prototype.toString.call(st.seq)==='[object Array]'){
      seq=st.seq.filter(function(x){ return x && (x.rest===true || typeof x.n==='number'); }).slice(0,100);
    }
    if(st && Object.prototype.toString.call(st.pat)==='[object Array]'){
      for(var pi=0;pi<8;pi++){
        var pt=st.pat[pi];
        PAT[pi]=(pt && pt.p && Object.prototype.toString.call(pt.seq)==='[object Array]')
          ? {name:slotLetter(pi),seq:clonePat(pt.seq),p:pt.p} : null;
      }
    }
    if(st && Object.prototype.toString.call(st.chain)==='[object Array]'){
      chain=st.chain.filter(function(i){ return typeof i==='number' && i>=0 && i<8 && PAT[i]; }).slice(0,64);
    }
    if(st && Object.prototype.toString.call(st.user)==='[object Array]'){
      userPatches=st.user.filter(function(x){ return x && typeof x.name==='string' && x.p; }).slice(0,24);
      rebuildPatchList();
    }
    if(st && st.model){
      var fm=FINISHES.indexOf(st.model)>=0 ? st.model : 'silver';
      document.documentElement.dataset.model=fm;
      $$('.swatch').forEach(function(o){ o.setAttribute('aria-pressed',o.dataset.model===fm?'true':'false'); });
    }
  }catch(e){}
})();

/* ---- MIDI ---- */
(function(){
  if(!navigator.requestMIDIAccess) return;
  try{
    navigator.requestMIDIAccess().then(function(acc){
      acc.inputs.forEach(function(inp){
        inp.onmidimessage=function(m){
          var d=m.data, cmd=d[0]&0xf0;
          if(cmd===0x90 && d[2]>0){ ensure(); noteOn(d[1]); }
          else if(cmd===0x80 || (cmd===0x90&&d[2]===0)) noteOff(d[1]);
          else if(cmd===0xe0){ padX=clamp(((d[2]*128+d[1])-8192)/8192,-1,1); padApply(); }
        };
      });
    },function(){});
  }catch(e){}
})();

/* ---- offline render and audio export ---- */
function rateOf(pp){ return Math.max(expMap(pp.lfoRate,0.1,30),0.08); }
function relOf(pp){ return expMap(pp.envR,0.003,10); }
function copyPatch(pp){ var o={}; for(var k in DEF) o[k]=(typeof pp[k]==='number')?pp[k]:DEF[k]; return o; }

function renderPattern(pat,sr){
  var pp=copyPatch(pat.p), steps=pat.seq.length;
  var rate=rateOf(pp), body=steps/rate;
  var tail=Math.min(Math.max(relOf(pp),0.35),4)+0.3;
  var len=Math.max(1,Math.ceil((body+tail)*sr));
  var OAC=window.OfflineAudioContext||window.webkitOfflineAudioContext;
  var oac=new OAC(1,len,sr);
  var sCtx=ctx,sA=A,sP=P,sNote=lastNote,sSound=sounding,sPow=powered,sInst=instantParams,sRend=rendering,sPadY=padY;
  try{
    ctx=oac; A={}; P=pp; lastNote=60; sounding=false; powered=true;
    instantParams=true; rendering=true; padY=0;
    buildGraph();
    setPitch(noteFreq(60),0,0);
    applyAll();
    var t=0.004, step=1/rate;
    for(var i=0;i<steps;i++){
      if(P.lfoWave===2) A.lfoRand.offset.setValueAtTime(Math.random()*2-1,t);
      var st2=pat.seq[i];
      if(st2.rest){ envRelease(t); gateOff(t); } else playStep(st2.n,t,step);
      t+=step;
    }
    envRelease(t); gateOff(t);
  } finally {
    ctx=sCtx; A=sA; P=sP; lastNote=sNote; sounding=sSound; powered=sPow;
    instantParams=sInst; rendering=sRend; padY=sPadY;
  }
  return oac.startRendering().then(function(b){ return {data:b.getChannelData(0), body:body}; });
}

function renderArrangement(sr){
  var items = chain.length ? chain.slice() : (PAT[patSel] ? [patSel] : []);
  items = items.filter(function(i){ return PAT[i] && PAT[i].seq.length; });
  if(!items.length) return Promise.resolve(null);
  var uniq=[], seen={};
  items.forEach(function(i){ if(!seen[i]){ seen[i]=1; uniq.push(i); } });
  var cache={};
  return uniq.reduce(function(chainP,i){
    return chainP.then(function(){
      return renderPattern(PAT[i],sr).then(function(r){ cache[i]=r; });
    });
  },Promise.resolve()).then(function(){
    var offs=[], total=0;
    items.forEach(function(i){ offs.push(total); total+=cache[i].body; });
    var lastLen=cache[items[items.length-1]].data.length/sr;
    var totalLen=Math.ceil((offs[offs.length-1]+lastLen)*sr);
    var out=new Float32Array(totalLen);
    items.forEach(function(idx,k){
      var d=cache[idx].data, off=Math.round(offs[k]*sr);
      for(var j=0;j<d.length;j++){ var w=off+j; if(w<totalLen) out[w]+=d[j]; }
    });
    var peak=0;
    for(var m=0;m<totalLen;m++){ var a=out[m]<0?-out[m]:out[m]; if(a>peak) peak=a; }
    if(peak>0.98){ var g=0.98/peak; for(var q=0;q<totalLen;q++) out[q]*=g; }
    var fade=Math.min(Math.round(sr*0.01),totalLen);
    for(var f=0;f<fade;f++) out[totalLen-1-f]*=f/fade;
    return {data:out, sr:sr, seconds:totalLen/sr};
  });
}

function encodeWav(samples,sr){
  var nn=samples.length, buf=new ArrayBuffer(44+nn*2), v=new DataView(buf);
  function str(o,t){ for(var i=0;i<t.length;i++) v.setUint8(o+i,t.charCodeAt(i)); }
  str(0,'RIFF'); v.setUint32(4,36+nn*2,true); str(8,'WAVE');
  str(12,'fmt '); v.setUint32(16,16,true); v.setUint16(20,1,true); v.setUint16(22,1,true);
  v.setUint32(24,sr,true); v.setUint32(28,sr*2,true); v.setUint16(32,2,true); v.setUint16(34,16,true);
  str(36,'data'); v.setUint32(40,nn*2,true);
  var o=44;
  for(var i=0;i<nn;i++){
    var x=samples[i]; x=x<-1?-1:(x>1?1:x);
    v.setInt16(o,x<0?x*0x8000:x*0x7FFF,true); o+=2;
  }
  return new Uint8Array(buf);
}

var CRC_T=null;
function crc32(u8){
  if(!CRC_T){
    CRC_T=new Int32Array(256);
    for(var i=0;i<256;i++){ var c=i; for(var k=0;k<8;k++) c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1); CRC_T[i]=c; }
  }
  var crc=-1;
  for(var j=0;j<u8.length;j++) crc=(crc>>>8)^CRC_T[(crc^u8[j])&0xFF];
  return (crc^-1)>>>0;
}
function zipStore(name,data){
  var enc=[]; for(var i=0;i<name.length;i++) enc.push(name.charCodeAt(i)&0xFF);
  var nl=enc.length, crc=crc32(data), sz=data.length;
  var total=30+nl+sz+46+nl+22;
  var out=new Uint8Array(total), v=new DataView(out.buffer), p=0;
  function u32(x){ v.setUint32(p,x,true); p+=4; }
  function u16(x){ v.setUint16(p,x,true); p+=2; }
  u32(0x04034b50); u16(20); u16(0); u16(0); u16(0); u16(0x21);
  u32(crc); u32(sz); u32(sz); u16(nl); u16(0);
  out.set(enc,p); p+=nl;
  out.set(data,p); p+=sz;
  var cd=p;
  u32(0x02014b50); u16(20); u16(20); u16(0); u16(0); u16(0); u16(0x21);
  u32(crc); u32(sz); u32(sz); u16(nl); u16(0); u16(0); u16(0); u16(0); u32(0); u32(0);
  out.set(enc,p); p+=nl;
  var cdSize=p-cd;
  u32(0x06054b50); u16(0); u16(0); u16(1); u16(1); u32(cdSize); u32(cd); u16(0);
  return out;
}

function exportStatus(msg){ exportMsg=msg; readout(); }
function exportAudio(){
  if(exporting) return;
  if(!(chain.length || PAT[patSel])){ exportStatus('Store a pattern first, then export.'); return; }
  exporting=true; updButtons(); exportStatus('<b>Rendering</b> \u2026');
  var sr=44100, base='sh-101-'+(chain.length?'chain':'pattern-'+slotLetter(patSel));
  renderArrangement(sr).then(function(res){
    if(!res){ throw {code:'empty'}; }
    var wav=encodeWav(res.data,sr);
    var secs=res.seconds.toFixed(1);
    var inShell=!!(window.claude && window.claude.use);
    var use=inShell ? window.claude.use('downloads') : Promise.resolve(null);
    exportStatus('<b>Preparing</b> \u2026 '+secs+'s rendered');
    return use.catch(function(){ return null; }).then(function(dl){
      if(dl && dl.save){
        var zip=zipStore(base+'.wav',wav);
        return dl.save({filename:base+'.zip', data:new Blob([zip])}).then(function(){
          exportStatus('<b>Saved</b> \u00b7 '+secs+'s WAV inside '+base+'.zip');
        });
      }
      if(inShell){ exportStatus('Saving is not available in this view.'); return; }
      var blob=new Blob([wav],{type:'audio/wav'});
      var url=URL.createObjectURL(blob);
      var a=document.createElement('a');
      a.href=url; a.download=base+'.wav';
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(function(){ URL.revokeObjectURL(url); },8000);
      exportStatus('<b>Exported</b> \u00b7 '+secs+'s WAV');
    });
  }).catch(function(err){
    var code=err&&err.code;
    if(code==='declined') exportStatus('Export cancelled.');
    else if(code==='empty') exportStatus('Nothing to export yet.');
    else if(code==='too_large') exportStatus('That arrangement is too large to save.');
    else exportStatus('Export failed \u2014 try a shorter chain.');
  }).then(function(){
    exporting=false; updButtons();
    setTimeout(function(){ exportMsg=''; readout(); },6000);
  });
}

/* ---- phosphor display ---- */
var scopeCv=$('#scope'), scopeC=null, freqData=null;
var ledCol='#ff3324', vfdCol='#ffae2b', glassCol='#140e07';
try{ if(scopeCv && scopeCv.getContext) scopeC=scopeCv.getContext('2d'); }catch(e){}
function readColours(){
  try{
    var cs=getComputedStyle(document.documentElement);
    ledCol=(cs.getPropertyValue('--led')||'').trim()||ledCol;
    vfdCol=(cs.getPropertyValue('--vfd')||'').trim()||vfdCol;
    glassCol=(cs.getPropertyValue('--glass')||'').trim()||glassCol;
  }catch(e){}
}
function tint(hex,a){
  var h=String(hex).replace('#','');
  if(h.length===3) h=h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
  var n=parseInt(h,16);
  if(h.length!==6||isNaN(n)) return 'rgba(255,174,43,'+a+')';
  return 'rgba('+((n>>16)&255)+','+((n>>8)&255)+','+(n&255)+','+a+')';
}
readColours();
function fitScope(){
  var dpr=Math.min(window.devicePixelRatio||1,2);
  var w=Math.round(scopeCv.clientWidth*dpr), h=Math.round(scopeCv.clientHeight*dpr);
  if(w>0 && h>0 && (scopeCv.width!==w || scopeCv.height!==h)){ scopeCv.width=w; scopeCv.height=h; }
  return dpr;
}
function drawScope(){
  if(!scopeC) return;
  var dpr=fitScope(), W=scopeCv.width, H=scopeCv.height, i;
  scopeC.fillStyle=glassCol; scopeC.fillRect(0,0,W,H);
  scopeC.strokeStyle=tint(vfdCol,.09); scopeC.lineWidth=dpr;
  scopeC.beginPath();
  for(i=1;i<4;i++){ var gx=Math.round(W*i/4)+.5; scopeC.moveTo(gx,0); scopeC.lineTo(gx,H); }
  for(i=1;i<3;i++){ var gy=Math.round(H*i/3)+.5; scopeC.moveTo(0,gy); scopeC.lineTo(W,gy); }
  scopeC.stroke();
  var base=H-3*dpr;
  if(!ctx||!powered||!A.an){
    scopeC.strokeStyle=tint(vfdCol,.3); scopeC.lineWidth=1.5*dpr;
    scopeC.beginPath(); scopeC.moveTo(0,base); scopeC.lineTo(W,base); scopeC.stroke();
    return;
  }
  if(!freqData) freqData=new Uint8Array(A.an.frequencyBinCount);
  A.an.getByteFrequencyData(freqData);
  var minF=40, maxF=16000, nyq=ctx.sampleRate/2, bins=freqData.length, lr=Math.log(maxF/minF);
  var step=2*dpr, pts=[];
  for(var x=0;;x+=step){
    var xx=Math.min(x,W);
    var f=minF*Math.exp(lr*xx/W);
    var b=Math.min(bins-1,Math.round(f/nyq*bins));
    pts.push(xx, base-(freqData[b]/255)*(base-2*dpr));
    if(xx>=W) break;
  }
  scopeC.beginPath(); scopeC.moveTo(0,H);
  for(i=0;i<pts.length;i+=2) scopeC.lineTo(pts[i],pts[i+1]);
  scopeC.lineTo(W,H); scopeC.closePath();
  var g=scopeC.createLinearGradient(0,0,0,H);
  g.addColorStop(0,tint(vfdCol,.42)); g.addColorStop(1,tint(vfdCol,.03));
  scopeC.fillStyle=g; scopeC.fill();
  scopeC.beginPath(); scopeC.moveTo(pts[0],pts[1]);
  for(i=2;i<pts.length;i+=2) scopeC.lineTo(pts[i],pts[i+1]);
  scopeC.strokeStyle=vfdCol; scopeC.lineWidth=1.2*dpr;
  scopeC.shadowColor=tint(vfdCol,.85); scopeC.shadowBlur=5*dpr;
  scopeC.stroke(); scopeC.shadowBlur=0;
  var ev=A.env.offset.value||0;
  var cf=clamp(filterBase()*Math.pow(2,(S.vcfEnvC()*ev)/1200),minF,maxF);
  var mx=Math.round(W*Math.log(cf/minF)/lr)+.5;
  scopeC.strokeStyle=ledCol; scopeC.lineWidth=1.5*dpr;
  scopeC.shadowColor=ledCol; scopeC.shadowBlur=6*dpr;
  scopeC.beginPath(); scopeC.moveTo(mx,0); scopeC.lineTo(mx,H); scopeC.stroke();
  scopeC.shadowBlur=0;
}

/* ---- animation ---- */
var frameN=0;
(function frame(){
  requestAnimationFrame(frame);
  if(window.__padSpring) window.__padSpring();
  var led=$('#lfoLed');
  if(ctx && powered){
    var ph=(ctx.currentTime*S.lfoRate())%1;
    led.dataset.on = ph<0.5 ? '1':'0';
  } else led.dataset.on='0';
  if((frameN++ & 1)===0) drawScope();
})();

refreshAll();
})();
</script>
</body>
</html>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.