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&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">−</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 & 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>