← TutSynth results

TutSynth

Kimi k3 · full

Initial view of TutSynth

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 56,909 bytes · SHA-256 9c01281292b6
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SYNTHLAB — Learn Synthesis by Playing</title>
<style>
:root{
  --bg:#07090d; --panel:#0f131a; --panel2:#141a24; --line:rgba(255,255,255,.07);
  --txt:#e9edf5; --dim:#8b94a7; --dim2:#5b6478;
  --mono:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
  font-family:"Avenir Next",Avenir,"Segoe UI",system-ui,-apple-system,sans-serif;
  background:var(--bg); color:var(--txt); overflow-x:hidden;
  -webkit-font-smoothing:antialiased;
}
.bg{position:fixed;inset:0;z-index:-2;background:
  radial-gradient(900px 520px at 12% -10%, rgba(34,211,238,.10), transparent 60%),
  radial-gradient(820px 520px at 92% 112%, rgba(192,132,252,.10), transparent 60%),
  var(--bg);}
.bg::after{content:'';position:absolute;inset:0;
  background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),
                   linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);
  background-size:44px 44px;
  -webkit-mask-image:radial-gradient(ellipse at 50% 0%,black,transparent 78%);
          mask-image:radial-gradient(ellipse at 50% 0%,black,transparent 78%);}
::selection{background:rgba(34,211,238,.35)}

/* ---------- header ---------- */
header.top{display:flex;align-items:center;gap:20px;padding:16px 24px 12px;flex-wrap:wrap}
.brand{flex:none}
.logo{font-weight:800;font-size:22px;letter-spacing:.16em}
.logo b{font-weight:800;color:transparent;background:linear-gradient(90deg,#22d3ee,#c084fc);
  -webkit-background-clip:text;background-clip:text}
.tagline{color:var(--dim);font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;margin-top:3px}
.scope-wrap{flex:1;min-width:240px;position:relative;height:78px;border:1px solid var(--line);
  border-radius:12px;background:#05070b;overflow:hidden}
#scope{width:100%;height:100%;display:block}
.note-readout{position:absolute;top:8px;left:12px;font:600 10.5px/1.3 var(--mono);
  color:#7ee7f9;letter-spacing:.1em;text-transform:uppercase;pointer-events:none}
.note-readout .hz{color:#4d5a70}

/* ---------- layout ---------- */
main{display:grid;grid-template-columns:350px 1fr;gap:18px;padding:6px 24px 26px;align-items:start}
#rack{display:flex;flex-direction:column;gap:16px;min-width:0}
.modules{display:grid;grid-template-columns:repeat(12,1fr);gap:14px}
.s3{grid-column:span 3}.s4{grid-column:span 4}.s5{grid-column:span 5}
.s6{grid-column:span 6}.s7{grid-column:span 7}

/* ---------- modules ---------- */
.mod{position:relative;background:linear-gradient(180deg,var(--panel2),var(--panel));
  border:1px solid var(--line);border-radius:14px;padding:13px 14px 15px;overflow:hidden;
  transition:border-color .3s}
.mod::before{content:'';position:absolute;inset:0 0 auto;height:1px;
  background:linear-gradient(90deg,transparent,rgba(255,255,255,.16),transparent)}
.mod:hover{border-color:rgba(255,255,255,.13)}
.mod-head{display:flex;align-items:center;gap:8px;margin-bottom:12px}
.mod-dot{width:8px;height:8px;border-radius:50%;background:var(--acc);box-shadow:0 0 10px var(--acc);flex:none}
.mod h3{font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:700}
.mod-sub{margin-left:auto;font-size:9px;color:var(--dim2);letter-spacing:.14em;text-transform:uppercase}
.mod-body{display:flex;gap:14px;align-items:center;justify-content:center;flex-wrap:wrap}
.mod-note{font-size:10px;color:var(--dim2);letter-spacing:.06em;text-align:center;width:100%;margin-top:2px}
@keyframes spotPulse{
  0%,100%{box-shadow:0 0 20px -8px var(--acc),inset 0 0 0 1px var(--acc)}
  50%{box-shadow:0 0 38px -4px var(--acc),inset 0 0 0 1px var(--acc)}}
.mod.spot{border-color:var(--acc);animation:spotPulse 2.4s ease-in-out infinite}

.lock{position:absolute;inset:0;z-index:5;background:rgba(7,9,13,.64);backdrop-filter:blur(2.5px);
  display:flex;flex-direction:column;gap:9px;align-items:center;justify-content:center;
  color:var(--dim);font-size:10px;letter-spacing:.16em;text-transform:uppercase;text-align:center;
  padding:12px;cursor:not-allowed}
.lock svg{width:20px;height:20px;stroke:var(--dim2)}
.mod.unlocked .lock{display:none}
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px)}75%{transform:translateX(5px)}}
.lock.shake{animation:shake .25s ease 2}

/* ---------- knobs ---------- */
.knob{display:flex;flex-direction:column;align-items:center;gap:4px;user-select:none;flex:none}
.knob-dial{width:52px;height:52px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
  background:conic-gradient(from 225deg,
    var(--acc,#22d3ee) 0deg, var(--acc,#22d3ee) var(--arc,0deg),
    rgba(255,255,255,.09) var(--arc,0deg), rgba(255,255,255,.09) 270deg,
    transparent 270deg, transparent 360deg)}
.knob-dial::before{content:'';position:absolute;inset:5px;border-radius:50%;
  background:radial-gradient(circle at 36% 30%,#242c3a,#10141d 72%);
  box-shadow:inset 0 1px 2px rgba(255,255,255,.14),0 4px 12px rgba(0,0,0,.55)}
.knob-rot{position:absolute;inset:6px;transform:rotate(-135deg);pointer-events:none}
.knob-rot::before{content:'';position:absolute;left:50%;top:1px;width:3px;height:11px;margin-left:-1.5px;
  border-radius:2px;background:var(--acc,#22d3ee);box-shadow:0 0 7px var(--acc,#22d3ee)}
.knob.drag .knob-dial{filter:brightness(1.25)}
.knob-val{font:600 10px/1 var(--mono);color:var(--dim);letter-spacing:.03em;white-space:nowrap}
.knob.drag .knob-val{color:var(--acc,#22d3ee)}
.knob-label{font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--dim2)}
.knob.small .knob-dial{width:42px;height:42px}
.knob.small .knob-rot{inset:5px}
header.top .knob{--acc:#7ee7f9}

.ctl{display:flex;flex-direction:column;align-items:center;gap:6px}
.ctl-label{font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--dim2)}

/* waveform buttons */
.waves{display:flex;gap:6px}
.waves button{width:38px;height:30px;border-radius:8px;border:1px solid var(--line);
  background:#0b0f16;display:grid;place-items:center;cursor:pointer;color:var(--dim);
  transition:all .15s;padding:0}
.waves button svg{width:22px;height:14px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round}
.waves button:hover{color:var(--txt);border-color:rgba(255,255,255,.2)}
.waves button.on{color:#06131a;background:var(--acc);border-color:var(--acc);box-shadow:0 0 14px -2px var(--acc)}

/* segmented toggle */
.seg{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
.seg button{padding:7px 12px;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;
  background:transparent;color:var(--dim);border:0;cursor:pointer;font-weight:600}
.seg button.on{background:var(--acc);color:#0a0d12;font-weight:800}

/* mini viz canvases */
.viz{width:100%;height:64px;background:#05070b;border:1px solid var(--line);border-radius:9px;display:block}
.viz-cap{font-size:9px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim2);text-align:center;width:100%}
</style>
<style>
/* ---------- lesson panel ---------- */
#lessons{background:linear-gradient(180deg,#10151d,#0b0e14);border:1px solid var(--line);
  border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:14px;
  position:sticky;top:14px}
.l-kicker{display:flex;justify-content:space-between;align-items:center;font:600 10px/1 var(--mono);
  letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
#skills{color:#34d399}
.dots{display:flex;gap:5px}
.dot{flex:1;height:4px;border-radius:2px;background:#1d2431;cursor:pointer;transition:background .3s;padding:0;border:0}
.dot.open{background:#39435a}
.dot.cur{background:linear-gradient(90deg,#22d3ee,#c084fc)}
#lTitle{font-size:21px;font-weight:800;letter-spacing:-.01em;line-height:1.15}
#lBody{font-size:13px;line-height:1.65;color:#bfc7d6}
#lBody strong{color:#fff;font-weight:700}
#lBody em{color:#9aa3b8}
#lBody ul{margin:8px 0 2px 2px;list-style:none}
#lBody li{margin:5px 0;padding-left:14px;position:relative}
#lBody li::before{content:'';position:absolute;left:0;top:8px;width:6px;height:2px;background:#22d3ee}
#lBody kbd{font:600 10.5px var(--mono);background:#1a2130;border:1px solid #2c3648;border-bottom-width:2px;
  border-radius:5px;padding:1px 5px;color:#dfe6f2}
.use{background:rgba(34,211,238,.05);border:1px solid rgba(34,211,238,.18);border-radius:11px;padding:11px 13px}
.use h4{font-size:9.5px;letter-spacing:.18em;color:#22d3ee;text-transform:uppercase;margin-bottom:7px;font-weight:800}
.use ul{list-style:none}
.use li{font-size:12px;color:#a9b3c6;line-height:1.5;margin:4px 0;padding-left:13px;position:relative}
.use li::before{content:'▸';position:absolute;left:0;color:#22d3ee;font-size:10px;top:1px}
.use li strong{color:#dbe2ee}
.task{display:flex;gap:11px;align-items:center;border:1px dashed rgba(255,255,255,.2);
  border-radius:11px;padding:11px 13px;font-size:12.5px;color:#cfd6e4;transition:all .3s}
.task .tick{width:21px;height:21px;border-radius:50%;border:1.5px solid var(--dim2);display:grid;
  place-items:center;color:transparent;flex:none;font-size:11px;font-weight:900}
.task.done{border:1px solid rgba(52,211,153,.55);color:#d9fcec;background:rgba(52,211,153,.06)}
.task.done .tick{background:#34d399;border-color:#34d399;color:#04281b}
@keyframes pop{50%{transform:scale(1.4)}}
.task.done .tick{animation:pop .4s ease}
.l-nav{display:flex;gap:10px;margin-top:2px}
.l-nav .btn{flex:1}

/* ---------- buttons ---------- */
.btn{border:1px solid var(--line);background:#0d1119;color:var(--txt);padding:10px 14px;border-radius:10px;
  font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;cursor:pointer;font-weight:700;
  transition:all .15s;font-family:inherit}
.btn:hover:not(:disabled){border-color:rgba(255,255,255,.25);background:#121826}
.btn:disabled{opacity:.35;cursor:default}
.btn.primary{background:linear-gradient(90deg,#22d3ee,#818cf8);color:#06121a;border:0}
.btn.primary:hover:not(:disabled){filter:brightness(1.12)}
.btn.primary.pulse{animation:btnPulse 1.6s ease-in-out infinite}
@keyframes btnPulse{0%,100%{box-shadow:0 0 0 0 rgba(34,211,238,.45)}50%{box-shadow:0 0 0 7px rgba(34,211,238,0)}}
.btn.mini{padding:6px 11px;font-size:10px;border-radius:8px}
.btn.toggle.on{background:rgba(34,211,238,.16);border-color:#22d3ee;color:#7ee7f9;box-shadow:0 0 12px -3px #22d3ee}

/* patches */
.patch-grid{display:flex;flex-wrap:wrap;gap:8px;justify-content:center}
.patch{padding:9px 13px;border-radius:9px;border:1px solid var(--line);background:#0c1018;color:var(--dim);
  font-size:10px;letter-spacing:.13em;text-transform:uppercase;cursor:pointer;font-weight:700;
  transition:all .15s;font-family:inherit}
.patch:hover{color:var(--txt);border-color:rgba(255,255,255,.28);transform:translateY(-1px)}
.patch:active{transform:translateY(0)}

/* ---------- keyboard ---------- */
.kb-wrap{background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--line);
  border-radius:14px;padding:14px 16px 16px}
.kb-top{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}
.oct-ctl{display:flex;align-items:center;gap:8px}
.oct-label{font:700 11px var(--mono);color:#7ee7f9;min-width:26px;text-align:center}
.kb-hint{font-size:10px;color:var(--dim2);letter-spacing:.06em;margin-left:auto}
.kb-scroll{overflow-x:auto;padding-bottom:4px}
.kb{position:relative;display:flex;height:150px;min-width:620px;border-radius:10px;
  user-select:none;touch-action:none}
.key{position:relative;cursor:pointer}
.key-w{flex:1;background:linear-gradient(180deg,#f5f7fb,#d7dce7);
  border-right:1px solid #aab2c2;border-radius:0 0 7px 7px;
  box-shadow:inset 0 -5px 8px rgba(0,0,0,.08)}
.key-w.on{background:linear-gradient(180deg,#a5ecfa,#5ccbe8);
  box-shadow:inset 0 -6px 14px rgba(14,116,144,.55)}
.key-b{position:absolute;top:0;height:60%;width:4.4%;z-index:2;
  background:linear-gradient(180deg,#3a4150,#0d1017);border-radius:0 0 6px 6px;
  border:1px solid #05070b;box-shadow:0 4px 8px rgba(0,0,0,.5)}
.key-b.on{background:linear-gradient(180deg,#37d5f1,#0e7490)}
.k-key{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);
  font:700 9px var(--mono);color:#98a2b8;text-transform:uppercase;pointer-events:none}
.key-b .k-key{bottom:8px;color:#77839c}
.k-note{position:absolute;bottom:5px;left:50%;transform:translateX(-50%);
  font:600 8.5px var(--mono);color:#7c8698;pointer-events:none}
.key-w.on .k-key,.key-w.on .k-note{color:#0c4a5e}

@media (prefers-reduced-motion:reduce){
  .mod.spot,.btn.primary.pulse,.task.done .tick{animation:none}
}

/* ---------- responsive ---------- */
@media (max-width:1080px){
  main{grid-template-columns:1fr}
  #lessons{position:static}
  .modules{grid-template-columns:repeat(2,1fr)}
  .s3,.s4,.s5,.s6,.s7{grid-column:auto}
  .span2m{grid-column:span 2}
}
@media (max-width:640px){
  main{padding:6px 12px 18px}
  header.top{padding:14px 14px 8px}
  .scope-wrap{order:3;flex-basis:100%}
  .modules{grid-template-columns:1fr}
  .span2m{grid-column:auto}
}
</style>
</head>
<body>
<div class="bg"></div>

<header class="top">
  <div class="brand">
    <div class="logo">SYNTH<b>LAB</b></div>
    <div class="tagline">Learn subtractive synthesis by playing</div>
  </div>
  <div class="scope-wrap">
    <canvas id="scope"></canvas>
    <div class="note-readout" id="noteReadout">press a key — A S D F G H J K</div>
  </div>
  <div class="knob small" data-param="volume" style="--acc:#7ee7f9"></div>
</header>

<main>
  <!-- ================= LESSON PANEL ================= -->
  <aside id="lessons">
    <div class="l-kicker"><span id="lNum">LESSON 1 / 9</span><span id="skills">0 / 9 SKILLS</span></div>
    <div class="dots" id="dots"></div>
    <h2 id="lTitle"></h2>
    <div id="lBody"></div>
    <div class="use" id="useBox"><h4>Use it to…</h4><ul id="lUse"></ul></div>
    <div class="task" id="taskBox"><span class="tick">✓</span><span id="taskText"></span></div>
    <div class="l-nav">
      <button class="btn" id="prevBtn">← Back</button>
      <button class="btn primary" id="nextBtn"></button>
    </div>
  </aside>

  <!-- ================= RACK ================= -->
  <section id="rack">
    <div class="modules">

      <section class="mod s3" id="mOsc1" style="--acc:#22d3ee">
        <div class="mod-head"><span class="mod-dot"></span><h3>Osc 1</h3><span class="mod-sub">source</span></div>
        <div class="mod-body">
          <div class="ctl">
            <div class="waves" data-param="osc1Wave">
              <button data-v="sine" title="Sine — pure, no overtones"><svg viewBox="0 0 22 14"><path d="M1 7 C 4.5 1.5, 7.5 1.5, 11 7 S 17.5 12.5, 21 7"/></svg></button>
              <button data-v="triangle" title="Triangle — soft odd harmonics, mellow"><svg viewBox="0 0 22 14"><path d="M1 12 L6 2 L11 12 L16 2 L21 12"/></svg></button>
              <button data-v="square" title="Square — strong odd harmonics, hollow &amp; woody"><svg viewBox="0 0 22 14"><path d="M1 12 V2 H11 V12 H21"/></svg></button>
              <button data-v="sawtooth" title="Sawtooth — every harmonic, bright &amp; brassy"><svg viewBox="0 0 22 14"><path d="M1 12 L11 2 V12 L21 2"/></svg></button>
            </div>
            <span class="ctl-label">Waveform</span>
          </div>
        </div>
        <div class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><span class="lock-txt"></span></div>
      </section>

      <section class="mod s5" id="mOsc2" style="--acc:#34d399">
        <div class="mod-head"><span class="mod-dot"></span><h3>Osc 2 + Mix</h3><span class="mod-sub">thickness</span></div>
        <div class="mod-body">
          <div class="ctl">
            <div class="waves" data-param="osc2Wave">
              <button data-v="sine" title="Sine"><svg viewBox="0 0 22 14"><path d="M1 7 C 4.5 1.5, 7.5 1.5, 11 7 S 17.5 12.5, 21 7"/></svg></button>
              <button data-v="triangle" title="Triangle"><svg viewBox="0 0 22 14"><path d="M1 12 L6 2 L11 12 L16 2 L21 12"/></svg></button>
              <button data-v="square" title="Square"><svg viewBox="0 0 22 14"><path d="M1 12 V2 H11 V12 H21"/></svg></button>
              <button data-v="sawtooth" title="Sawtooth"><svg viewBox="0 0 22 14"><path d="M1 12 L11 2 V12 L21 2"/></svg></button>
            </div>
            <span class="ctl-label">Waveform</span>
          </div>
          <div class="knob" data-param="osc2Detune"></div>
          <div class="knob" data-param="mix"></div>
        </div>
        <div class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><span class="lock-txt"></span></div>
      </section>

      <section class="mod s4" id="mFilter" style="--acc:#fb923c">
        <div class="mod-head"><span class="mod-dot"></span><h3>Filter</h3><span class="mod-sub">low-pass</span></div>
        <div class="mod-body">
          <div class="knob" data-param="cutoff"></div>
          <div class="knob" data-param="res"></div>
          <div class="ctl" style="flex:1;min-width:110px">
            <canvas class="viz" id="fcurve"></canvas>
            <span class="viz-cap">frequency response</span>
          </div>
        </div>
        <div class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><span class="lock-txt"></span></div>
      </section>

      <section class="mod s6" id="mAmpEnv" style="--acc:#a3e635">
        <div class="mod-head"><span class="mod-dot"></span><h3>Amp Envelope</h3><span class="mod-sub">loudness over time</span></div>
        <div class="mod-body">
          <div class="knob" data-param="attack"></div>
          <div class="knob" data-param="decay"></div>
          <div class="knob" data-param="sustain"></div>
          <div class="knob" data-param="release"></div>
          <div class="ctl" style="flex:1;min-width:130px">
            <canvas class="viz" id="adsrViz"></canvas>
            <span class="viz-cap">plays as you play</span>
          </div>
        </div>
        <div class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><span class="lock-txt"></span></div>
      </section>

      <section class="mod s3" id="mFiltEnv" style="--acc:#facc15">
        <div class="mod-head"><span class="mod-dot"></span><h3>Filter Env</h3><span class="mod-sub">movement</span></div>
        <div class="mod-body">
          <div class="knob" data-param="fenvAmount"></div>
          <div class="knob" data-param="fenvDecay"></div>
          <div class="mod-note">envelope → cutoff</div>
        </div>
        <div class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><span class="lock-txt"></span></div>
      </section>

      <section class="mod s3" id="mLfo" style="--acc:#c084fc">
        <div class="mod-head"><span class="mod-dot"></span><h3>LFO</h3><span class="mod-sub">auto-wiggle</span></div>
        <div class="mod-body">
          <div class="ctl">
            <div class="seg" id="lfoDest">
              <button data-v="pitch" title="Vibrato — pitch gently rises and falls">Pitch</button>
              <button data-v="filter" title="Wobble — cutoff opens and closes">Filter</button>
            </div>
            <span class="ctl-label">Destination</span>
          </div>
          <div class="knob" data-param="lfoRate"></div>
          <div class="knob" data-param="lfoDepth"></div>
        </div>
        <div class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><span class="lock-txt"></span></div>
      </section>

      <section class="mod s5" id="mDelay" style="--acc:#f472b6">
        <div class="mod-head"><span class="mod-dot"></span><h3>Delay</h3><span class="mod-sub">space</span></div>
        <div class="mod-body">
          <div class="knob" data-param="delayTime"></div>
          <div class="knob" data-param="delayFeedback"></div>
          <div class="knob" data-param="delayMix"></div>
        </div>
        <div class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><span class="lock-txt"></span></div>
      </section>

      <section class="mod s7" id="mPatch" style="--acc:#818cf8">
        <div class="mod-head"><span class="mod-dot"></span><h3>Patches</h3><span class="mod-sub">presets to dissect</span></div>
        <div class="mod-body">
          <div class="patch-grid">
            <button class="patch" data-preset="init">Init</button>
            <button class="patch" data-preset="sub">Sub Bass</button>
            <button class="patch" data-preset="pluck">Pluck</button>
            <button class="patch" data-preset="pad">Warm Pad</button>
            <button class="patch" data-preset="wobble">Wobble</button>
            <button class="patch" data-preset="lead">Lead</button>
          </div>
        </div>
        <div class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><span class="lock-txt"></span></div>
      </section>

    </div>

    <!-- ================= KEYBOARD ================= -->
    <div class="kb-wrap">
      <div class="kb-top">
        <div class="oct-ctl">
          <button class="btn mini" id="octDown">−</button>
          <span class="oct-label" id="octLabel">C4</span>
          <button class="btn mini" id="octUp">+</button>
        </div>
        <button class="btn mini toggle" id="holdBtn" title="Latch notes so your hands are free to turn knobs">Hold</button>
        <span class="kb-hint">play: <b>A–;</b> row · black keys <b>W E T Y U O P</b> · octave <b>Z / X</b> · knobs: drag ↑↓ (shift = fine), double-click resets</span>
      </div>
      <div class="kb-scroll"><div class="kb" id="kb"></div></div>
    </div>
  </section>
</main>
<script>
'use strict';
/* ============================== utils ============================== */
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
const NOTE_NAMES=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const midiName=m=>NOTE_NAMES[m%12]+(Math.floor(m/12)-1);
const midiFreq=m=>440*Math.pow(2,(m-69)/12);
const fmtHz=v=>v>=1000?(v/1000).toFixed(v>=10000?0:1)+'k Hz':Math.round(v)+' Hz';
const fmtMs=v=>v<1?Math.round(v*1000)+' ms':v.toFixed(2)+' s';
const fmtPct=v=>Math.round(v*100)+'%';

/* ============================== params ============================== */
const PARAMS={
  volume:      {label:'Volume',  min:0,   max:1,    def:.7,  fmt:fmtPct, tip:'Master output level'},
  osc2Detune:  {label:'Detune',  min:-50, max:50,   def:0,   fmt:v=>(v>0?'+':'')+Math.round(v)+' ¢', tip:'OSC 2 pitch offset in cents (100¢ = 1 semitone)'},
  mix:         {label:'Mix 1·2', min:0,   max:1,    def:.5,  fmt:v=>(100-Math.round(v*100))+' / '+Math.round(v*100), tip:'Balance: oscillator 1 ←→ oscillator 2'},
  cutoff:      {label:'Cutoff',  min:40,  max:16000,def:12000,scale:'log',fmt:fmtHz, tip:'Frequencies above this point are removed'},
  res:         {label:'Reso',    min:.1,  max:12,   def:.8,  fmt:v=>v.toFixed(1), tip:'Boost at the cutoff frequency — whistly at high settings'},
  attack:      {label:'Attack',  min:.002,max:2,    def:.01, scale:'log',fmt:fmtMs, tip:'Fade-in time. Slow = swelling pads'},
  decay:       {label:'Decay',   min:.01, max:2,    def:.18, scale:'log',fmt:fmtMs, tip:'Time to fall from peak to sustain level'},
  sustain:     {label:'Sustain', min:0,   max:1,    def:.8,  fmt:fmtPct, tip:'Level held while the key is down'},
  release:     {label:'Release', min:.01, max:3,    def:.3,  scale:'log',fmt:fmtMs, tip:'Fade-out after key up. Long = notes blur together'},
  fenvAmount:  {label:'Env Amt', min:0,   max:100,  def:0,   fmt:v=>Math.round(v)+'%', tip:'How far each note pushes the filter open'},
  fenvDecay:   {label:'Env Decay',min:.01,max:2,    def:.25, scale:'log',fmt:fmtMs, tip:'How fast the filter closes again'},
  lfoRate:     {label:'Rate',    min:.05, max:20,   def:4,   scale:'log',fmt:v=>v.toFixed(2)+' Hz', tip:'LFO speed: 0.2 Hz = slow drift, 5 Hz = vibrato'},
  lfoDepth:    {label:'Depth',   min:0,   max:1,    def:0,   fmt:fmtPct, tip:'How strongly the LFO wiggles its destination'},
  delayTime:   {label:'Time',    min:.03, max:1,    def:.32, scale:'log',fmt:fmtMs, tip:'Gap between echoes'},
  delayFeedback:{label:'Fdbk',   min:0,   max:.9,   def:.35, fmt:fmtPct, tip:'How many echoes. High = near-infinite'},
  delayMix:    {label:'Mix',     min:0,   max:1,    def:0,   fmt:fmtPct, tip:'Echo level blended with the dry sound'}
};
const state={};
for(const k in PARAMS) state[k]=PARAMS[k].def;
Object.assign(state,{osc1Wave:'sawtooth',osc2Wave:'sawtooth',lfoDest:'pitch',octave:4});

/* ============================== audio engine ============================== */
let AC=null, master, comp, analyser, voiceBus, delayIn, delayNode, delayFb, delayWet, lfo;
const voices=new Map();          // midi -> voice
const held=new Set();            // latched notes (hold mode)
let holdMode=false;

function ensureAudio(){
  if(AC){ if(AC.state==='suspended') AC.resume(); return; }
  try{
    AC=new (window.AudioContext||window.webkitAudioContext)();
  }catch(e){ return; }
  master=AC.createGain(); master.gain.value=state.volume*state.volume;
  comp=AC.createDynamicsCompressor();
  comp.threshold.value=-14; comp.knee.value=22; comp.ratio.value=5;
  comp.attack.value=.003; comp.release.value=.22;
  analyser=AC.createAnalyser(); analyser.fftSize=2048;
  master.connect(comp); comp.connect(analyser); analyser.connect(AC.destination);
  voiceBus=AC.createGain(); voiceBus.gain.value=.85; voiceBus.connect(master);
  // delay send/return
  delayIn=AC.createGain(); delayNode=AC.createDelay(1.5);
  delayFb=AC.createGain(); delayWet=AC.createGain();
  delayNode.delayTime.value=state.delayTime;
  delayFb.gain.value=state.delayFeedback;
  delayWet.gain.value=state.delayMix;
  voiceBus.connect(delayIn); delayIn.connect(delayNode);
  delayNode.connect(delayFb); delayFb.connect(delayNode);
  delayNode.connect(delayWet); delayWet.connect(master);
  // global LFO (per-voice taps are created with each voice)
  lfo=AC.createOscillator(); lfo.type='sine'; lfo.frequency.value=state.lfoRate; lfo.start();
}

function mixGains(){ const m=state.mix*Math.PI/2; return [Math.cos(m)*.9, Math.sin(m)*.9]; }
function lfoPitchAmt(){ return state.lfoDest==='pitch' ? state.lfoDepth*90 : 0; }
function lfoFilterAmt(){ return state.lfoDest==='filter' ? state.lfoDepth*Math.max(state.cutoff,80) : 0; }

function noteOn(midi){
  ensureAudio(); if(!AC) return;
  if(holdMode && held.has(midi)){ held.delete(midi); releaseVoice(midi); paintKey(midi,false); return; }
  if(holdMode) held.add(midi);
  if(voices.has(midi)) killVoice(midi);
  const t=AC.currentTime, f=midiFreq(midi);
  const o1=AC.createOscillator(), o2=AC.createOscillator();
  o1.type=state.osc1Wave; o2.type=state.osc2Wave;
  o1.frequency.value=f; o2.frequency.value=f; o2.detune.value=state.osc2Detune;
  const g1=AC.createGain(), g2=AC.createGain();
  const [m1,m2]=mixGains(); g1.gain.value=m1; g2.gain.value=m2;
  const flt=AC.createBiquadFilter(); flt.type='lowpass'; flt.Q.value=state.res;
  const amp=AC.createGain(); amp.gain.value=0;
  // filter envelope: jump open, fall back to base cutoff
  const base=state.cutoff;
  const peak=Math.min(base*Math.pow(2,(state.fenvAmount/100)*4),18000);
  flt.frequency.setValueAtTime(peak,t);
  flt.frequency.exponentialRampToValueAtTime(Math.max(base,30),t+Math.max(state.fenvDecay,.01));
  // amp ADSR
  const a=Math.max(state.attack,.003), d=Math.max(state.decay,.008);
  amp.gain.setValueAtTime(0,t);
  amp.gain.linearRampToValueAtTime(1,t+a);
  amp.gain.linearRampToValueAtTime(state.sustain,t+a+d);
  // LFO taps
  const lp=AC.createGain(), lf=AC.createGain();
  lp.gain.value=lfoPitchAmt(); lf.gain.value=lfoFilterAmt();
  lfo.connect(lp); lp.connect(o1.detune); lp.connect(o2.detune);
  lfo.connect(lf); lf.connect(flt.frequency);
  o1.connect(g1); g1.connect(flt);
  o2.connect(g2); g2.connect(flt);
  flt.connect(amp); amp.connect(voiceBus);
  o1.start(t); o2.start(t);
  voices.set(midi,{o1,o2,g1,g2,flt,amp,lp,lf,released:false});
  paintKey(midi,true);
  $('#noteReadout').innerHTML=midiName(midi)+' <span class="hz">· '+midiFreq(midi).toFixed(1)+' Hz</span>';
  envVizOn(t);
  synthEvent('noteOn',{midi});
}

function releaseVoice(midi){
  const v=voices.get(midi); if(!v||v.released) return;
  v.released=true;
  const t=AC.currentTime, r=Math.max(state.release,.02);
  v.amp.gain.cancelScheduledValues(t);
  v.amp.gain.setValueAtTime(v.amp.gain.value,t);
  v.amp.gain.linearRampToValueAtTime(0,t+r);
  try{ v.o1.stop(t+r+.06); v.o2.stop(t+r+.06); }catch(e){}
  setTimeout(()=>{ disconnectVoice(v); if(voices.get(midi)===v) voices.delete(midi); },(r+.12)*1000);
}

function killVoice(midi){
  const v=voices.get(midi); if(!v) return;
  voices.delete(midi);
  const t=AC.currentTime;
  try{
    v.amp.gain.cancelScheduledValues(t);
    v.amp.gain.setValueAtTime(v.amp.gain.value,t);
    v.amp.gain.linearRampToValueAtTime(0,t+.015);
    v.o1.stop(t+.04); v.o2.stop(t+.04);
  }catch(e){}
  setTimeout(()=>disconnectVoice(v),80);
}

function disconnectVoice(v){
  [v.o1,v.o2,v.g1,v.g2,v.flt,v.amp,v.lp,v.lf].forEach(n=>{ try{n.disconnect();}catch(e){} });
}

function anySounding(){ for(const v of voices.values()) if(!v.released) return true; return false; }

function noteOff(midi){
  if(!AC) return;
  if(holdMode) return; // latched — keep ringing until Hold is released / key re-pressed
  releaseVoice(midi);
  paintKey(midi,false);
  if(!anySounding()) envVizOff(AC.currentTime);
  synthEvent('noteOff',{midi});
}

function allNotesOff(){
  [...voices.keys()].forEach(releaseVoice);
  held.clear();
  $$('.key.on').forEach(k=>k.classList.remove('on'));
  if(AC && !anySounding()) envVizOff(AC.currentTime);
}

/* live param changes */
function applyParam(name){
  if(!AC) { if(name==='cutoff'||name==='res') drawFcurve(); return; }
  const t=AC.currentTime;
  switch(name){
    case 'volume': master.gain.setTargetAtTime(state.volume*state.volume,t,.02); break;
    case 'cutoff':
      voices.forEach(v=>{ v.flt.frequency.cancelScheduledValues(t);
        v.flt.frequency.setTargetAtTime(state.cutoff,t,.015); });
      voices.forEach(v=>v.lf.gain.setTargetAtTime(lfoFilterAmt(),t,.02));
      drawFcurve(); break;
    case 'res': voices.forEach(v=>v.flt.Q.setTargetAtTime(state.res,t,.015)); drawFcurve(); break;
    case 'osc1Wave': voices.forEach(v=>{ v.o1.type=state.osc1Wave; }); break;
    case 'osc2Wave': voices.forEach(v=>{ v.o2.type=state.osc2Wave; }); break;
    case 'osc2Detune': voices.forEach(v=>v.o2.detune.setTargetAtTime(state.osc2Detune,t,.01)); break;
    case 'mix': { const [m1,m2]=mixGains();
      voices.forEach(v=>{ v.g1.gain.setTargetAtTime(m1,t,.01); v.g2.gain.setTargetAtTime(m2,t,.01); }); break; }
    case 'lfoRate': lfo.frequency.setTargetAtTime(state.lfoRate,t,.02); break;
    case 'lfoDepth': case 'lfoDest':
      voices.forEach(v=>{ v.lp.gain.setTargetAtTime(lfoPitchAmt(),t,.02);
        v.lf.gain.setTargetAtTime(lfoFilterAmt(),t,.02); }); break;
    case 'delayTime': delayNode.delayTime.setTargetAtTime(state.delayTime,t,.06); break;
    case 'delayFeedback': delayFb.gain.setTargetAtTime(state.delayFeedback,t,.06); break;
    case 'delayMix': delayWet.gain.setTargetAtTime(state.delayMix,t,.04); break;
  }
}
</script>
<script>
'use strict';
/* ============================== knobs ============================== */
const knobs={};
function buildKnob(el){
  const param=el.dataset.param, p=PARAMS[param];
  el.innerHTML='<div class="knob-dial"><div class="knob-rot"></div></div>'+
               '<div class="knob-val"></div><div class="knob-label">'+p.label+'</div>';
  el.title=(p.tip?p.tip+' — ':'')+'drag ↑↓ · shift = fine · double-click = reset';
  el.setAttribute('role','slider'); el.setAttribute('aria-label',p.label);
  const dial=el.querySelector('.knob-dial'), rot=el.querySelector('.knob-rot'), val=el.querySelector('.knob-val');
  const toNorm=v=>p.scale==='log'?Math.log(v/p.min)/Math.log(p.max/p.min):(v-p.min)/(p.max-p.min);
  const toVal=n=>{ n=clamp(n,0,1); return p.scale==='log'?p.min*Math.pow(p.max/p.min,n):p.min+n*(p.max-p.min); };
  function render(){
    const n=toNorm(state[param]);
    rot.style.transform='rotate('+(-135+n*270)+'deg)';
    dial.style.setProperty('--arc',(n*270)+'deg');
    val.textContent=p.fmt(state[param]);
    el.setAttribute('aria-valuenow',state[param].toFixed(3));
  }
  function set(v,src){
    state[param]=clamp(v,p.min,p.max);
    render(); applyParam(param);
    synthEvent('param',{name:param,value:state[param],src:src||'ui'});
  }
  let drag=null;
  dial.addEventListener('pointerdown',e=>{
    e.preventDefault(); ensureAudio();
    try{ dial.setPointerCapture(e.pointerId); }catch(err){}
    drag={y:e.clientY,n:toNorm(state[param])}; el.classList.add('drag');
  });
  dial.addEventListener('pointermove',e=>{
    if(!drag) return;
    const dy=(drag.y-e.clientY)/(e.shiftKey?1400:170);
    set(toVal(drag.n+dy),'drag');
  });
  const end=()=>{ drag=null; el.classList.remove('drag'); };
  dial.addEventListener('pointerup',end);
  dial.addEventListener('pointercancel',end);
  dial.addEventListener('dblclick',()=>set(p.def,'reset'));
  dial.addEventListener('wheel',e=>{
    e.preventDefault(); ensureAudio();
    const d=(e.deltaY<0?1:-1)*(e.shiftKey?.0025:.025);
    set(toVal(toNorm(state[param])+d),'wheel');
  },{passive:false});
  knobs[param]={set,render,el};
  render();
}

/* ============================== waveforms & segments ============================== */
function refreshWaves(){
  $$('.waves').forEach(w=>{
    const param=w.dataset.param;
    w.querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.v===state[param]));
  });
  $$('#lfoDest button').forEach(b=>b.classList.toggle('on',b.dataset.v===state.lfoDest));
}
function initWaveUI(){
  $$('.waves').forEach(w=>{
    const param=w.dataset.param;
    w.addEventListener('click',e=>{
      const b=e.target.closest('button'); if(!b) return;
      state[param]=b.dataset.v; refreshWaves(); applyParam(param);
      synthEvent('param',{name:param,value:b.dataset.v});
    });
  });
  $('#lfoDest').addEventListener('click',e=>{
    const b=e.target.closest('button'); if(!b) return;
    state.lfoDest=b.dataset.v; refreshWaves(); applyParam('lfoDest');
    synthEvent('param',{name:'lfoDest',value:b.dataset.v});
  });
}

/* ============================== keyboard ============================== */
const kb=$('#kb');
const WHITE_SEMI=[0,2,4,5,7,9,11];
const BLACK_AFTER={1:0,3:1,6:3,8:4,10:5};
const COMP=['a','w','s','e','d','f','t','g','y','h','u','j','k','o','l','p',';'];
const keyEls=new Map();
const semiToMidi=semi=>12*(state.octave+1)+semi;

function buildKeyboard(){
  kb.innerHTML=''; keyEls.clear();
  for(let oct=0;oct<2;oct++){
    for(let w=0;w<7;w++){
      const semi=WHITE_SEMI[w]+oct*12;
      const el=document.createElement('div');
      el.className='key key-w'; el.dataset.semi=semi;
      el.innerHTML='<span class="k-key">'+(COMP[semi]||'')+'</span><span class="k-note"></span>';
      kb.appendChild(el); keyEls.set(semi,el);
    }
  }
  for(let oct=0;oct<2;oct++){
    for(const s in BLACK_AFTER){
      const semi=+s+oct*12, wi=BLACK_AFTER[s]+oct*7;
      const el=document.createElement('div');
      el.className='key key-b'; el.dataset.semi=semi;
      el.style.left='calc('+(((wi+1)/14)*100).toFixed(3)+'% - 2.2%)';
      el.innerHTML='<span class="k-key">'+(COMP[semi]||'')+'</span>';
      kb.appendChild(el); keyEls.set(semi,el);
    }
  }
  updateKbLabels();
}
function updateKbLabels(){
  keyEls.forEach((el,semi)=>{
    const n=el.querySelector('.k-note');
    if(n) n.textContent=(semi%12===0)?midiName(semiToMidi(semi)):'';
  });
  $('#octLabel').textContent='C'+state.octave;
}
function setOctave(o){
  state.octave=clamp(o,2,6); updateKbLabels();
}
function paintKey(midi,on){
  const semi=midi-12*(state.octave+1);
  const el=keyEls.get(semi);
  if(el) el.classList.toggle('on',on);
}

const pointerNotes=new Map();
kb.addEventListener('pointerdown',e=>{
  const k=e.target.closest('.key'); if(!k) return;
  e.preventDefault();
  const m=semiToMidi(+k.dataset.semi);
  pointerNotes.set(e.pointerId,m);
  noteOn(m);
});
function pointerRelease(e){
  const m=pointerNotes.get(e.pointerId);
  if(m!=null){ pointerNotes.delete(e.pointerId); noteOff(m); }
}
window.addEventListener('pointerup',pointerRelease);
window.addEventListener('pointercancel',pointerRelease);
kb.addEventListener('contextmenu',e=>e.preventDefault());

const compDown=new Map();
window.addEventListener('keydown',e=>{
  if(e.repeat||e.metaKey||e.ctrlKey||e.altKey) return;
  const k=e.key.toLowerCase();
  if(k==='z'){ setOctave(state.octave-1); return; }
  if(k==='x'){ setOctave(state.octave+1); return; }
  const i=COMP.indexOf(k); if(i<0||compDown.has(k)) return;
  const m=semiToMidi(i);
  compDown.set(k,m); noteOn(m);
});
window.addEventListener('keyup',e=>{
  const k=e.key.toLowerCase();
  const m=compDown.get(k);
  if(m!=null){ compDown.delete(k); noteOff(m); }
});
window.addEventListener('blur',()=>{ compDown.clear(); pointerNotes.clear(); allNotesOff(); });

$('#octDown').addEventListener('click',()=>setOctave(state.octave-1));
$('#octUp').addEventListener('click',()=>setOctave(state.octave+1));
$('#holdBtn').addEventListener('click',()=>{
  holdMode=!holdMode;
  $('#holdBtn').classList.toggle('on',holdMode);
  if(!holdMode){
    [...held].forEach(m=>{ releaseVoice(m); paintKey(m,false); });
    held.clear();
    if(AC && !anySounding()) envVizOff(AC.currentTime);
  }
});
$('#holdBtn').addEventListener('dblclick',()=>{ holdMode=false; $('#holdBtn').classList.remove('on'); allNotesOff(); });
</script>
<script>
'use strict';
/* ============================== visualizations ============================== */
function fitCanvas(c){
  const r=c.getBoundingClientRect(), d=window.devicePixelRatio||1;
  const w=Math.max(1,Math.round(r.width*d)), h=Math.max(1,Math.round(r.height*d));
  if(c.width!==w||c.height!==h){ c.width=w; c.height=h; }
  const g=c.getContext('2d'); g.setTransform(d,0,0,d,0,0);
  return {w:r.width,h:r.height,g};
}

/* --- oscilloscope --- */
function drawScope(){
  const c=$('#scope'); if(!c) return;
  const {w,h,g}=fitCanvas(c);
  g.clearRect(0,0,w,h);
  g.strokeStyle='rgba(255,255,255,.06)'; g.lineWidth=1;
  g.beginPath(); g.moveTo(0,h/2); g.lineTo(w,h/2); g.stroke();
  const grad=g.createLinearGradient(0,0,w,0);
  grad.addColorStop(0,'#22d3ee'); grad.addColorStop(.55,'#818cf8'); grad.addColorStop(1,'#f472b6');
  g.lineWidth=2; g.strokeStyle=grad; g.shadowBlur=10; g.shadowColor='rgba(56,189,248,.55)';
  g.beginPath();
  if(analyser && AC){
    const buf=new Uint8Array(analyser.fftSize);
    analyser.getByteTimeDomainData(buf);
    // trigger on a rising zero-crossing so the trace is stable
    let start=0;
    for(let i=1;i<buf.length/2;i++){ if(buf[i-1]<128&&buf[i]>=128){ start=i; break; } }
    const win=Math.min(820,buf.length-start);
    const step=win/w;
    for(let x=0;x<w;x++){
      const v=buf[Math.min(buf.length-1,Math.floor(start+x*step))]/128-1;
      const y=h/2+v*(h/2-6);
      x===0?g.moveTo(x,y):g.lineTo(x,y);
    }
  } else {
    g.moveTo(0,h/2); g.lineTo(w,h/2);
  }
  g.stroke(); g.shadowBlur=0;
}

/* --- filter frequency response --- */
let offAC=null;
function drawFcurve(){
  const c=$('#fcurve'); if(!c) return;
  if(!offAC){ try{ offAC=new OfflineAudioContext(1,128,44100); }catch(e){ return; } }
  const {w,h,g}=fitCanvas(c);
  g.clearRect(0,0,w,h);
  const f=offAC.createBiquadFilter();
  f.type='lowpass'; f.frequency.value=state.cutoff; f.Q.value=state.res;
  const N=72, freqs=new Float32Array(N), mag=new Float32Array(N), ph=new Float32Array(N);
  for(let i=0;i<N;i++) freqs[i]=20*Math.pow(1000,i/(N-1)); // 20 Hz .. 20 kHz
  f.getFrequencyResponse(freqs,mag,ph);
  const xOf=fr=>{ const t=Math.log(fr/20)/Math.log(1000); return 4+t*(w-8); };
  const yOf=db=>{ const top=26, bot=-42; return 4+(1-(db-bot)/(top-bot))*(h-8); };
  // grid lines at 100 / 1k / 10k
  g.strokeStyle='rgba(255,255,255,.05)'; g.lineWidth=1;
  [100,1000,10000].forEach(fr=>{ const x=xOf(fr); g.beginPath(); g.moveTo(x,0); g.lineTo(x,h); g.stroke(); });
  // curve
  g.beginPath();
  for(let i=0;i<N;i++){
    const db=20*Math.log10(Math.max(mag[i],1e-4));
    const x=xOf(freqs[i]), y=yOf(clamp(db,-42,26));
    i===0?g.moveTo(x,y):g.lineTo(x,y);
  }
  g.strokeStyle='#fb923c'; g.lineWidth=2; g.shadowBlur=8; g.shadowColor='rgba(251,146,60,.6)';
  g.stroke(); g.shadowBlur=0;
  // soft fill under curve
  g.lineTo(xOf(freqs[N-1]),h-2); g.lineTo(xOf(freqs[0]),h-2); g.closePath();
  g.fillStyle='rgba(251,146,60,.10)'; g.fill();
  // cutoff marker
  const cx=xOf(clamp(state.cutoff,20,20000));
  g.strokeStyle='rgba(251,146,60,.5)'; g.setLineDash([3,3]);
  g.beginPath(); g.moveTo(cx,2); g.lineTo(cx,h-2); g.stroke(); g.setLineDash([]);
}

/* --- ADSR envelope viz (animated) --- */
let envPhase='idle', envT0=0, envTr=0, envRelFrom=0;
function envLevelOn(t){
  const a=Math.max(state.attack,.003), d=Math.max(state.decay,.008), s=state.sustain;
  if(t<0) return 0;
  if(t<a) return t/a;
  const k=(t-a)/d;
  if(k<1) return 1-(1-s)*k;
  return s;
}
function envVizOn(t){ envPhase='on'; envT0=t; }
function envVizOff(t){
  if(envPhase!=='on') return;
  envPhase='rel'; envTr=t; envRelFrom=envLevelOn(t-envT0);
}
function drawAdsr(){
  const c=$('#adsrViz'); if(!c) return;
  const {w,h,g}=fitCanvas(c);
  g.clearRect(0,0,w,h);
  const yOf=v=>h-4-v*(h-10);
  const xA=w*.22, xD=w*.42, xS=w*.68, xR=w*.96;
  // static curve
  g.beginPath();
  g.moveTo(2,yOf(0));
  g.lineTo(xA,yOf(1));
  g.lineTo(xD,yOf(state.sustain));
  g.lineTo(xS,yOf(state.sustain));
  g.lineTo(xR,yOf(0));
  g.strokeStyle='rgba(163,230,53,.85)'; g.lineWidth=2;
  g.shadowBlur=7; g.shadowColor='rgba(163,230,53,.5)'; g.stroke(); g.shadowBlur=0;
  // segment labels
  g.fillStyle='rgba(139,148,167,.75)';
  g.font='700 8px ui-monospace,monospace';
  g.fillText('A',xA/2-2,h-4); g.fillText('D',(xA+xD)/2-2,h-4);
  g.fillText('S',(xD+xS)/2-2,h-4); g.fillText('R',(xS+xR)/2-2,h-4);
  // animated dot
  if(!AC || envPhase==='idle') return;
  const now=AC.currentTime;
  let lvl,x;
  if(envPhase==='on'){
    const t=now-envT0, a=Math.max(state.attack,.003), d=Math.max(state.decay,.008);
    lvl=envLevelOn(t);
    if(t<a) x=2+(xA-2)*(t/a);
    else if(t<a+d) x=xA+(xD-xA)*((t-a)/d);
    else x=Math.min(xD+(xS-xD)*((t-a-d)/1.4), xS);
  } else {
    const t=now-envTr, r=Math.max(state.release,.02), k=clamp(t/r,0,1);
    lvl=envRelFrom*(1-k);
    x=xS+(xR-xS)*k;
    if(k>=1){ envPhase='idle'; return; }
  }
  g.beginPath(); g.arc(x,yOf(lvl),3.4,0,Math.PI*2);
  g.fillStyle='#eaffb0'; g.shadowBlur=10; g.shadowColor='#a3e635'; g.fill(); g.shadowBlur=0;
}

function vizLoop(){
  requestAnimationFrame(vizLoop);
  drawScope();
  drawAdsr();
}
window.addEventListener('resize',()=>drawFcurve());
</script>
<script>
'use strict';
/* ============================== lessons ============================== */
const lessons=[
{
 title:'Make a sound',
 body:`Every synthesizer starts with an <strong>oscillator</strong> — a circuit that repeats a waveform hundreds of times per second. The speed of repetition is <strong>frequency</strong>, which we hear as <strong>pitch</strong>: a wave cycling 262 times per second is middle C (261.6 Hz).<br><br>Play the keyboard below — click it, or use your computer keys <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd> <kbd>F</kbd> <kbd>G</kbd>… (white) and <kbd>W</kbd> <kbd>E</kbd> <kbd>T</kbd>… (black). The scope up top draws your sound wave, live.`,
 use:[
  '<strong>Hold</strong> latches notes so both hands are free for knobs — you will want it soon.',
  'Octave buttons (or <kbd>Z</kbd> / <kbd>X</kbd>) shift the keyboard range.'
 ],
 taskText:'Play any 3 notes.',
 check(e,d,f){ if(e==='noteOn'){ f.n=(f.n||0)+1; return f.n>=3; } },
 next:'Waveforms'
},
{
 title:'Waveforms — the DNA of timbre',
 body:`Same pitch, different <strong>timbre</strong> (tone color). A waveform's shape decides which <strong>harmonics</strong> (overtones) it carries:
 <ul><li><strong>Sine</strong> — pure, zero overtones. Smooth and round.</li>
 <li><strong>Triangle</strong> — a whisper of odd harmonics. Mellow.</li>
 <li><strong>Square</strong> — strong odd harmonics. Hollow, woody, reedy.</li>
 <li><strong>Sawtooth</strong> — <em>every</em> harmonic. Bright, brassy — the classic raw material of synth sounds.</li></ul>
 Hold a note and flip through them — the scope redraws each shape.`,
 use:[
  'Pick <strong>saw</strong> when you plan to sculpt with the filter (Lesson 4) — it has harmonics to carve away.',
  'Pick <strong>sine / triangle</strong> for clean sub-bass and soft keys.'
 ],
 taskText:'Hold a note (try Hold) and switch waveforms while it rings.',
 check(e,d){ return e==='param' && d.name==='osc1Wave'; },
 next:'Osc 2 + Detune'
},
{
 title:'Two oscillators — instant width',
 body:`One oscillator sounds thin; pros stack at least two. Nudge <strong>DETUNE</strong> a few <strong>cents</strong> (a cent is 1/100 of a semitone) and the two waves drift in and out of phase, producing a shimmering <em>beating</em> — the secret behind fat supersaws and warm pads. <strong>MIX</strong> sets how much of each oscillator you hear.`,
 use:[
  '<strong>±5–15¢</strong> — subtle, chorus-like fattening.',
  '<strong>±20–50¢</strong> — honky, lo-fi, detuned character.',
  'Give OSC 2 a <strong>different waveform</strong> for richer harmonics.'
 ],
 taskText:'While a note rings, turn DETUNE a few cents and hear the slow wobble.',
 check(e,d){ return e==='param' && d.name==='osc2Detune' && Math.abs(d.value)>=3; },
 next:'the Filter'
},
{
 title:'The filter — sculpt by subtraction',
 body:`A <strong>low-pass filter</strong> lets low frequencies through and erases everything above the <strong>CUTOFF</strong>. This is <em>subtractive synthesis</em>: start harmonically rich (saw!), then carve. <strong>RESONANCE</strong> adds a boost right at the cutoff point — a whistly peak that makes sweeps scream. Watch the response curve bend as you turn the knobs.`,
 use:[
  'Low cutoff = dark, muffled, "underwater". High = open and bright.',
  'Crank resonance for acid squelch and wah-like sweeps.',
  'Slow cutoff sweeps over a held chord = instant drama.'
 ],
 taskText:'Hold notes and sweep CUTOFF across its range.',
 check(e,d){ return e==='param' && d.name==='cutoff'; },
 next:'the Amp Envelope'
},
{
 title:'Amp envelope — shape every note',
 body:`The envelope redraws <strong>loudness over time</strong> for each note, in four stages — the graph animates as you play:
 <ul><li><strong>Attack</strong> — fade-in speed. Slow = swelling pads, tape-reverse feel.</li>
 <li><strong>Decay</strong> — fall time from the peak down to…</li>
 <li><strong>Sustain</strong> — the level held while the key stays down.</li>
 <li><strong>Release</strong> — fade-out after you let go. Long = notes blur together.</li></ul>`,
 use:[
  '<strong>Pluck</strong>: instant A, sustain 0, short D &amp; R.',
  '<strong>Pad</strong>: attack ≈ 1 s, high sustain, long release.',
  '<strong>Staccato keys</strong>: everything short.'
 ],
 taskText:'Build a pluck (fast Attack, Sustain 0), then stretch ATTACK to ~1 s and play a pad chord.',
 check(e,d,f){
  if(e==='param' && ['attack','decay','sustain','release'].includes(d.name)) f.a=1;
  return !!(f.a && e==='noteOn');
 },
 next:'the Filter Envelope'
},
{
 title:'Filter envelope — the "womp"',
 body:`Envelopes can steer more than volume. <strong>ENV AMT</strong> throws the envelope at the filter cutoff, so every note starts brighter, then the filter closes over <strong>ENV DECAY</strong> time. That brightness-dive is the sound of funk clavs, house basses and punchy kick drums.`,
 use:[
  '<strong>Punchy bass</strong>: amount 40–70%, decay 100–300 ms.',
  '<strong>Drum-like thump</strong>: high amount, very short decay, sustain 0.',
  'Amount 0 = the envelope leaves the filter alone.'
 ],
 taskText:'Set ENV AMT above ~50% with a short ENV DECAY, then play short punchy notes.',
 check(e,d,f){
  if(e==='param' && d.name==='fenvAmount' && d.value>10) f.a=1;
  return !!(f.a && e==='noteOn');
 },
 next:'the LFO'
},
{
 title:'LFO — motion on autopilot',
 body:`An <strong>LFO</strong> (low-frequency oscillator) wobbles too slowly to hear — 0.1 to 20 cycles per second — so instead of making sound, it <em>wiggles other controls for you</em>. Destination <strong>PITCH</strong> = vibrato, like a violinist's finger. <strong>FILTER</strong> = rhythmic wobble (dubstep wubs, sweeping pads). <strong>RATE</strong> is speed, <strong>DEPTH</strong> is amount.`,
 use:[
  '<strong>Vibrato lead</strong>: pitch, depth ~10–20%, rate 4–6 Hz.',
  '<strong>Wobble bass</strong>: filter, big depth, rate to taste.',
  '<strong>Slow drift</strong> on pads: filter, rate 0.2 Hz, gentle depth.'
 ],
 taskText:'Set destination PITCH, DEPTH ~20%, RATE ~5 Hz — hold a note and hear it sing.',
 check(e,d,f){
  if(e==='param' && d.name==='lfoDepth' && d.value>.05) f.a=1;
  return !!(f.a && e==='noteOn');
 },
 next:'Delay'
},
{
 title:'Delay — a sense of space',
 body:`<strong>Delay</strong> repeats your sound. <strong>TIME</strong> sets the gap between repeats, <strong>FDBK</strong> how many repeats (high = near-infinite), <strong>MIX</strong> how loud the echoes are. Instant depth — and at very short times, a metallic doubler.`,
 use:[
  '<strong>Spacious leads / plucks</strong>: 300–450 ms, mix ~25%.',
  'Play <em>in the gaps</em> — sparse notes + echoes = rhythm for free.',
  '<strong>Ambient wash</strong>: long time, high feedback, big mix.'
 ],
 taskText:'Add delay (MIX ~30%) and play sparse, staccato notes.',
 check(e,d,f){
  if(e==='param' && d.name==='delayMix' && d.value>.05) f.a=1;
  return !!(f.a && e==='noteOn');
 },
 next:'Free Play'
},
{
 title:'Free play — combine everything',
 body:`You now own the classic signal path: <strong>OSC → OSC → FILTER → AMP</strong>, animated by envelopes and an LFO, finished with delay. Every patch on the right is built <em>only</em> from controls you have learned — load one, read the knob positions to see how it's made, then bend it into your own sound.`,
 use:[
  'Design a <strong>bass</strong>: saw + low cutoff + filter-env punch.',
  'A <strong>pad</strong>: detune + slow attack + delay.',
  'A <strong>lead</strong>: square + vibrato + a touch of echo.'
 ],
 taskText:'Load a patch, then tweak three knobs to make it yours.',
 check(e,d,f){
  if(e==='preset') f.p=1;
  if(f.p && e==='param'){ f.n=(f.n||0)+1; }
  return !!(f.p && f.n>=3);
 },
 next:null
}];

/* ============================== lesson engine ============================== */
const MOD_UNLOCK=[['mOsc1',1],['mOsc2',2],['mFilter',3],['mAmpEnv',4],['mFiltEnv',5],['mLfo',6],['mDelay',7],['mPatch',8]];
let cur=0, maxUnlocked=0, flags={};
const skills=new Set();

function renderLocks(){
  MOD_UNLOCK.forEach(([id,ln])=>{
    const m=$('#'+id), locked=ln>maxUnlocked;
    m.classList.toggle('unlocked',!locked);
    m.classList.toggle('spot',ln===cur&&!locked);
    if(locked) m.querySelector('.lock-txt').textContent='Unlocks in Lesson '+(ln+1);
  });
}
function renderDots(){
  const d=$('#dots'); d.innerHTML='';
  lessons.forEach((L,i)=>{
    const b=document.createElement('button');
    b.className='dot'+(i<=maxUnlocked?' open':'')+(i===cur?' cur':'');
    b.title='Lesson '+(i+1)+': '+L.title;
    b.addEventListener('click',()=>{ if(i<=maxUnlocked) gotoLesson(i); });
    d.appendChild(b);
  });
}
function renderTask(){
  const L=lessons[cur], tb=$('#taskBox');
  if(!L.taskText){ tb.style.display='none'; return; }
  tb.style.display='flex';
  tb.classList.toggle('done',skills.has(cur));
  $('#taskText').textContent=(skills.has(cur)?'Nice — skill unlocked! ':'Try it: ')+L.taskText;
}
function renderSkills(){ $('#skills').textContent=skills.size+' / '+lessons.length+' SKILLS'; }
function renderLesson(){
  const L=lessons[cur];
  $('#lNum').textContent='LESSON '+(cur+1)+' / '+lessons.length;
  $('#lTitle').textContent=L.title;
  $('#lBody').innerHTML=L.body;
  $('#lUse').innerHTML=(L.use||[]).map(u=>'<li>'+u+'</li>').join('');
  $('#useBox').style.display=(L.use&&L.use.length)?'block':'none';
  flags={};
  renderTask(); renderDots(); renderLocks(); renderSkills();
  $('#prevBtn').disabled=cur===0;
  const nb=$('#nextBtn');
  if(cur===lessons.length-1){ nb.textContent='Course complete ✓'; nb.disabled=true; }
  else { nb.disabled=false; nb.textContent=(L.next?'Unlock '+L.next:'Next')+' →'; }
}
function gotoLesson(i){
  cur=clamp(i,0,lessons.length-1);
  maxUnlocked=Math.max(maxUnlocked,cur);
  renderLesson();
}

function blip(){
  if(!AC) return;
  const t=AC.currentTime, o=AC.createOscillator(), g=AC.createGain();
  o.type='sine';
  o.frequency.setValueAtTime(660,t); o.frequency.setValueAtTime(990,t+.09);
  g.gain.setValueAtTime(0,t);
  g.gain.linearRampToValueAtTime(.1,t+.012);
  g.gain.exponentialRampToValueAtTime(.0001,t+.32);
  o.connect(g); g.connect(master);
  o.start(t); o.stop(t+.35);
}

function synthEvent(name,data){
  const L=lessons[cur];
  if(L && L.check && !skills.has(cur)){
    let ok=false;
    try{ ok=L.check(name,data||{},flags); }catch(e){}
    if(ok){
      skills.add(cur); flags={};
      renderTask(); renderSkills(); blip();
      const nb=$('#nextBtn');
      nb.classList.add('pulse'); setTimeout(()=>nb.classList.remove('pulse'),3400);
    }
  }
}

/* ============================== presets ============================== */
const PRESETS={
 init:  {osc1Wave:'sawtooth',osc2Wave:'sawtooth',osc2Detune:0,mix:.5,cutoff:12000,res:.8,attack:.01,decay:.18,sustain:.8,release:.3,fenvAmount:0,fenvDecay:.25,lfoRate:4,lfoDepth:0,lfoDest:'pitch',delayTime:.32,delayFeedback:.35,delayMix:0},
 sub:   {osc1Wave:'sine',osc2Wave:'triangle',osc2Detune:0,mix:.3,cutoff:220,res:1,attack:.005,decay:.3,sustain:.9,release:.14,fenvAmount:55,fenvDecay:.16,lfoDepth:0,delayMix:0},
 pluck: {osc1Wave:'sawtooth',osc2Wave:'square',osc2Detune:6,mix:.62,cutoff:900,res:4,attack:.002,decay:.26,sustain:0,release:.12,fenvAmount:70,fenvDecay:.15,lfoDepth:0,delayTime:.3,delayFeedback:.35,delayMix:.22},
 pad:   {osc1Wave:'sawtooth',osc2Wave:'sawtooth',osc2Detune:14,mix:.5,cutoff:1100,res:2,attack:.7,decay:.5,sustain:.75,release:1.4,fenvAmount:22,fenvDecay:.8,lfoDest:'filter',lfoRate:.2,lfoDepth:.16,delayTime:.45,delayFeedback:.42,delayMix:.3},
 wobble:{osc1Wave:'sawtooth',osc2Wave:'sawtooth',osc2Detune:8,mix:.5,cutoff:300,res:6,attack:.01,decay:.2,sustain:1,release:.2,fenvAmount:0,lfoDest:'filter',lfoRate:2.2,lfoDepth:.8,delayMix:0},
 lead:  {osc1Wave:'square',osc2Wave:'sawtooth',osc2Detune:5,mix:.55,cutoff:3200,res:2,attack:.02,decay:.2,sustain:.85,release:.25,fenvAmount:0,lfoDest:'pitch',lfoRate:5,lfoDepth:.18,delayTime:.36,delayFeedback:.3,delayMix:.22}
};
function applyPreset(name){
  const p=PRESETS[name]; if(!p) return;
  Object.assign(state,p);
  for(const k in knobs){ knobs[k].render(); applyParam(k); }
  refreshWaves(); drawFcurve();
  synthEvent('preset',{name});
}

/* ============================== init ============================== */
$$('.knob[data-param]').forEach(buildKnob);
initWaveUI(); refreshWaves();
buildKeyboard();
$$('.lock').forEach(l=>l.addEventListener('click',()=>{
  l.classList.remove('shake'); void l.offsetWidth; l.classList.add('shake');
}));
$$('.patch').forEach(b=>b.addEventListener('click',()=>applyPreset(b.dataset.preset)));
$('#prevBtn').addEventListener('click',()=>gotoLesson(cur-1));
$('#nextBtn').addEventListener('click',()=>gotoLesson(cur+1));
renderLesson();
drawFcurve();
vizLoop();
document.addEventListener('pointerdown',()=>ensureAudio(),{once:true});

/* debug / test interface */
window.SYNTH={
  state,PARAMS,voices,noteOn,noteOff,allNotesOff,gotoLesson,applyPreset,ensureAudio,skills,lessons,
  setParam:(n,v)=>{ if(knobs[n]) knobs[n].set(v,'debug'); },
  ac:()=>AC,
  get cur(){return cur}, get maxUnlocked(){return maxUnlocked}
};
</script>
</body>
</html>